diff --git a/CHANGELOG/goals.md b/CHANGELOG/goals.md index 68e63682..075a1e84 100644 --- a/CHANGELOG/goals.md +++ b/CHANGELOG/goals.md @@ -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. diff --git a/CHANGELOG/pages/proto-home-v5.md b/CHANGELOG/pages/proto-home-v5.md index 62ded50a..f0596b24 100644 --- a/CHANGELOG/pages/proto-home-v5.md +++ b/CHANGELOG/pages/proto-home-v5.md @@ -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
; 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. diff --git a/goals/scratchnode/002-host-public-write-verification.md b/goals/scratchnode/002-host-public-write-verification.md index 9b4bb446..32f18ab6 100644 --- a/goals/scratchnode/002-host-public-write-verification.md +++ b/goals/scratchnode/002-host-public-write-verification.md @@ -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**. diff --git a/goals/scratchnode/003-privacy-boundary-honesty-gates.md b/goals/scratchnode/003-privacy-boundary-honesty-gates.md index 6829d1f5..ac0cb33b 100644 --- a/goals/scratchnode/003-privacy-boundary-honesty-gates.md +++ b/goals/scratchnode/003-privacy-boundary-honesty-gates.md @@ -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. diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index 231671f5..ef0b579a 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -6581,14 +6581,16 @@

Keyboard shortcuts

}; 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.'); diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index 0760cf44..dbc74636 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -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); + }); });