Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG/goals.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,8 @@ First OS self-review (5 lenses + reducer, 29 findings -> top 10) recorded to goa
Added the agent-governance layer: GOVERNANCE.md (LOW/MEDIUM/HIGH merge classes + may/may-not rails, mapped to the REAL npm/Vercel/Convex CI + branch protection — not the generic pnpm example), AGENT_LOOP.md (11-step research→patch→PR loop + reference budget + VIS-001..010), prompts/agent-library.md (6 chained agent prompts). Live-inspected branch protection and flagged honest gaps (enforce_admins=false; privacy/visual not required) as a HIGH-risk Goal Card (runtime/003) for founder approval — NOT auto-applied, since branch-protection changes are a no-autonomy zone.

**Commit**: `this commit`. **Author**: Homen Shum + Claude.

## 2026-06-02 — Executed scratchnode/002 + scratchnode/003 (boundary)
scratchnode/002 SHIPPED: host public-write actions now use the strict verified-host helper (PR consolidates with /003). scratchnode/003 SHIPPED (core): added CI-locked boundary tests SN-LIVE-007 (private send creates NO events:sendMessage) + SN-LIVE-008 (private note saved privately) + a public control. 006/009/010/012 honestly scoped to the demo spec (need rendered answers/wiki). enforce_admins (runtime/003) attempted but the agent token lacks branch-protection write (HTTP 404) -> correctly a founder action. 10/10 e2e green.

**Commit**: `this commit`. **Author**: Homen Shum + Claude.
5 changes: 5 additions & 0 deletions CHANGELOG/pages/proto-home-v5.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,8 @@ Centralized lightweight motion tokens and added a visual polish pass across the
Self-improvement loop cycle C002 added `type="button"` to the Memory Wall sticky-delete button and the two onboarding-tour buttons (Next/Skip) — they had onclick handlers but no explicit type (implicit-submit footgun). Validated as not inside a <form>; no behavior change. e2e honesty + output-contract green.

**Commit**: `this commit`. **Author**: Homen Shum + Claude.

## 2026-06-02 — security: host public-write actions require a verified host token (goal scratchnode/002)
snPromoteFaq + snPublishWiki now use the strict _snRequireVerifiedHostOwnerKey() + early-return, matching the other 5 host mutations, instead of the weak _snReadHostOwnerKey() that fell back to sessionId. Backend requireHost already gated these (so no breach), but the frontend now fails cleanly with "Host verification required" and never attempts the mutation as a guest. New e2e: guest cannot trigger promoteAnswerToFaq/publishWiki. 8/8 honesty+contract green.

**Commit**: `this commit`. **Author**: Homen Shum + Claude.
2 changes: 1 addition & 1 deletion goals/scratchnode/002-host-public-write-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ falls back to `sessionId` (6377-6383), while the other 5 host mutations use the
`_snRequireVerifiedHostOwnerKey()` (6233) that returns null + a "Host verification required" toast.
Make the two public-write actions consistent with the rest, and add a regression test.

- **status:** proposed
- **status:** shipped
- **surface:** scratchnode
- **severity:** **P1** (NOT P0). **Verified:** the backend `requireHost` (`convex/events.ts:439`,
called at 2626 + 2642) already rejects a bare `sessionId` server-side → **no public write occurs**.
Expand Down
2 changes: 1 addition & 1 deletion goals/scratchnode/003-privacy-boundary-honesty-gates.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ covers config/join/send/staleness/host-CRUD — **none assert the actual data bo
notes never leak into the public feed, the agent trace, or the published wiki). Add the missing
scenario tests so a regression that leaks a private note is caught by CI, not by users.

- **status:** proposed
- **status:** shipped (SN-LIVE-007/-008 + public control); 006/009/010/012 queued for the demo/output-contract spec (need rendered answers + wiki the live mock does not produce)
- **surface:** scratchnode
- **severity:** P1 (the core safety contract is currently unverified by CI)
- **auto-safe:** tests-only, no product code change → eligible for the loop's auto-safe path once approved.
Expand Down
6 changes: 4 additions & 2 deletions public/proto/home-v5.html
Original file line number Diff line number Diff line change
Expand Up @@ -6581,14 +6581,16 @@ <h2 id="kbd-title">Keyboard shortcuts</h2>
};

window.snPromoteFaq = function(answerId) {
const ownerKey = _snReadHostOwnerKey();
const ownerKey = _snRequireVerifiedHostOwnerKey('sn-manage-event-output');
if (!ownerKey) return;
client.mutation('events:promoteAnswerToFaq', { eventId, answerId, ownerKey })
.then(() => toast('Promoted to FAQ', 'This answer is ready for the event wiki.'))
.catch((e) => toast('Promotion blocked', e.message || String(e)));
};

window.snPublishWiki = function() {
const ownerKey = _snReadHostOwnerKey();
const ownerKey = _snRequireVerifiedHostOwnerKey('sn-manage-event-output');
if (!ownerKey) return;
client.mutation('events:publishWiki', { eventId, ownerKey })
.then((res) => {
toast('Wiki published', 'Version ' + res.version + ' is now live.');
Expand Down
91 changes: 91 additions & 0 deletions tests/e2e/scratchnode-live-route-honesty.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,4 +290,95 @@ test.describe("ScratchNode live route honesty", () => {
await expect(page.locator("#sn-manage-event-output")).toContainText("Session ended");
await expect(page.locator("#ev-mode-label")).toContainText("ended");
});

test("guest cannot trigger host-only public-write mutations (promoteFaq / publishWiki)", async ({
page,
}) => {
await fulfillScratchNodePage(page);
await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" });
await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true");

// Guest session: no sn_host_owner_key_v2 was set, so _sn_live.hostVerified is false.
// The two PUBLIC-write actions must early-return via _snRequireVerifiedHostOwnerKey,
// NOT fall back to sessionId (the C002/scratchnode-002 boundary fix). Backend requireHost
// would also reject, but the frontend must not even attempt the mutation.
await page.evaluate(() => {
const w = window as any;
if (typeof w.snPromoteFaq === "function") w.snPromoteFaq("liveEventAnswers:1");
if (typeof w.snPublishWiki === "function") w.snPublishWiki();
});

const calledMutations = await page.evaluate(() =>
((window as any).__snMockMutations || []).map((c: any) => c.name),
);
expect(calledMutations).not.toContain("events:promoteAnswerToFaq");
expect(calledMutations).not.toContain("events:publishWiki");
await expect(page.locator(".toast")).toContainText("Host verification required");
});

test("SN-LIVE-007/-008 private send saves privately and creates NO public message", async ({
page,
}) => {
await fulfillScratchNodePage(page);
await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" });
await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true");

const result = await page.evaluate(() => {
const w = window as any;
const countBefore =
typeof w.getPrivateNoteHandoffCount === "function"
? w.getPrivateNoteHandoffCount()
: (w._privateNotes_v5 || []).length;
// Enter private mode, then send a uniquely-identifiable private note.
if (document.body.getAttribute("data-mode") !== "private") w.toggleLock();
const input = document.getElementById("ci") as HTMLInputElement;
input.value = "PRIV-secret-latency-budget-xyz";
input.dispatchEvent(new Event("input", { bubbles: true }));
w.sendComposerMessage();
const countAfter =
typeof w.getPrivateNoteHandoffCount === "function"
? w.getPrivateNoteHandoffCount()
: (w._privateNotes_v5 || []).length;
return { countBefore, countAfter };
});

// SN-LIVE-007: the private text must NEVER reach a public eventMessages write.
const sentPublicly = await page.evaluate(() =>
((window as any).__snMockMutations || []).some(
(c: any) =>
c.name === "events:sendMessage" &&
JSON.stringify(c.args || {}).includes("secret-latency-budget-xyz"),
),
);
expect(sentPublicly).toBe(false);
// SN-LIVE-008: the private note IS captured privately (notebook count grew).
expect(result.countAfter).toBeGreaterThan(result.countBefore);
});

test("public send DOES create a public message (boundary control)", async ({ page }) => {
await fulfillScratchNodePage(page);
await page.goto("https://scratchnode.live/e/orbital", { waitUntil: "domcontentloaded" });
await expect(page.locator("body")).toHaveAttribute("data-sn-live", "true");

await page.evaluate(() => {
const w = window as any;
if (document.body.getAttribute("data-mode") === "private") w.toggleLock();
const input = document.getElementById("ci") as HTMLInputElement;
input.value = "PUBLIC-hello-everyone-xyz";
input.dispatchEvent(new Event("input", { bubbles: true }));
w.sendComposerMessage();
});

await expect
.poll(
() =>
page.evaluate(() =>
((window as any).__snMockMutations || []).some(
(c: any) => c.name === "events:sendMessage",
),
),
{ timeout: 5000 },
)
.toBe(true);
});
});
Loading