Skip to content

LIM-2424: fix every finding from the adversarial review of #5 (stacked on #5) - #6

Closed
liminalshruti wants to merge 9 commits into
agent/LIM-2424-l0-review-fixesfrom
agent/LIM-2424-pr5-adversarial-fixes
Closed

liminalshruti wants to merge 9 commits into
agent/LIM-2424-l0-review-fixesfrom
agent/LIM-2424-pr5-adversarial-fixes

Conversation

@liminalshruti

@liminalshruti liminalshruti commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Update (Oct 22, iMac S1): the fixes from Sean's review of this PR (Fixes 1–5 and the Lows) landed on #7's branch, which stacks on this one: 3888e5e, c652cf0, 4b7a255, bc67edf (now at 260a252). Sean approved #7 at bc67edf. #6 and #7 land as one unit after the pitch.

Fixes every finding from the adversarial review of PR #5 (LIM-2424 comment 9cd21ead). Draft, stacked on #5 (base agent/LIM-2424-l0-review-fixes). Not for merge before the pitch; the founder decides merges.

Needs Sean

  • Dependency bump: next and eslint-config-next 16.4.0 → 16.4.2 (critical advisories on 16.4.0; npm audit --omit=dev is 0 after). Commit 2daa3ef.
  • Auth, reset and persistence touched: lib/auth.ts (409 when the page names another workspace), lib/workspace.ts (agent epoch, invite claim order), lib/agent-token.ts, lib/workspace-setup.ts (Restart revokes agents), lib/store.ts (archive rotation, 0600).

Findings → fixes

# Finding Fix
H1 Consent couldn't be revoked; a token kept capturing after Restart Workspace capture needs the consent record. Tokens carry an agent epoch and expire after 7 days. DELETE /api/workspaces/agent-token disconnects one repo or all. Restart revokes every agent (founder ruling).
H2 Next 16.4.0 critical advisories 16.4.2
M1 Invite burned under a sign-in flood The invite is claimed after hashing and unclaimed on any failure; creation's hashing never 429s; at most 2 sign-ins in flight per client (IPv6 keyed by /64).
M2 Acks could pad state to 413 4 KB capped JSON; only a real authority event is recorded.
M3 A token for repo B could ack repo A's session Poll, ack, stream and the MCP work-map/directives tools are scoped to the token's harness and repo; a bad agt. token is 401, never the default store.
M4 Cross-port text/plain reset of the default store Same-origin (port included) + JSON on every default-store cookie write; local-only guards compare the port.
M5 Unbounded audit archive Rotates at 1 MB (two files max); Restart and connect limited to 10/min per workspace.
M6 ALIGN_DELETE_BY not enforced Refused at startup once passed; 410 on every request from that day; scheduled_snapshots = false.
M7 A stale tab wrote to the wrong workspace The page sends x-align-workspace; mismatch → 409 and the Elsewhere notice.
M8 Loading could hang Error + Try again.
M9 Notice vanished when config fetch failed /new notice is server-rendered.
M10 .tmp-*/.deleting-* leftovers Swept at startup and by ws-admin delete --all.
Lows listening map, junk-role eviction, invite oracle, state.json 0644, argv secrets, ALIGN_PUBLIC outside the 503 fallback All fixed. Fly-Client-IP stays a presence check (documented limit).

Tests

  • New regressions in test-tenancy: H1 (Restart revokes, product DELETE), M1, M2, M3, M4, M5 (loop + rotation), M6 (passed date refused), M7.
  • Vacuous checks made real: H1 refusals must exit or answer 503; U checks fail without Chrome; the U2 marker is asserted on the page; W5 checks the event id; W6 revokes through the product route instead of hand-editing auth.json; S9 checks the refusal names the key; the test-image listing needs a real listing.
  • Docs: HOSTING.md routes, refusals, H1, snapshots, test order; MCP.md scope and revocation.

Gate

At 13f41b2bf958a5e256390e3d1dcc93f5916e0443, scripts/test-all.sh (iMac, port 3240): all 15 steps PASS

  • Northstar snapshot identical to 850971e; test-flow stdout identical; test-scenario 57/57 ×3; test-ui-loop 19/19; test-mcp, test-capture, test-mapper pass; test-tenancy 133/133 (production builds, hosted phase included); ui-northstar 72/72 identical to 850971e.
  • Independent Northstar /api/state diff against 850971e after the full flow: only org_draft (null → absent), as before.

This is a regression check plus new targeted tests. It is not a security review; Sean's review is the gate.

https://claude.ai/code/session_01NdA1ZeBBUXwxCGPZcJhfJM

…e (agent epoch, 7-day expiry, Restart revokes)

H1 from the adversarial review of PR #5. An agent token was treated as the consent itself, so Restart erased the
consent record while the token kept capturing, and nothing in the app could revoke a token.
- Capture through an agent token requires hasCaptureConsent for its harness and repo, checked under the
  workspace write lock, and never adopts a session another repo started.
- auth.json gains agent_epoch (falls back to epoch), so revoking agents never signs people out.
- Agent tokens carry exp (7 days); tokens without exp are refused.
- Restart revokes every agent token (agent epoch bump) along with consents and captured sessions.
- DELETE /api/workspaces/agent-token (workspace leadership, JSON, same origin): removes one repo's consent and
  captured sessions, or all with {}, and bumps the agent epoch.
…ds Sean's approval)

H2 from the adversarial review of PR #5. npm audit: next 16.0.0-16.4.0 carries two critical unauthenticated RCE
advisories (GHSA-2464-fhr4-w9xw image optimizer + SVG; GHSA-673q-mjm6-r5xr use cache typed arrays) and a high DoS
(GHSA-p3m5-59mp-jv5r Server Actions). Patch-level bump; npm audit --omit=dev is clean afterwards. Dependency
change per the Build A plan section 5: flagged for Sean.
…t starve creation

M1 from the adversarial review of PR #5 (reproduced: a create during a wrong-passcode flood got 429, then its
single-use invite answered 'already used' with no workspace).
- createWorkspace hashes and writes everything first (hashing queues for a slot, never 429), then claims the
  invite, takes the rate token and renames; any failure after the claim gives the invite back. A spent or wrong
  code is refused before a rate token is taken.
- Hosted: at most 2 sign-ins in flight per client, correct passcodes included, so one client can't fill the
  shared scrypt pool. Mode L has one client key and is not capped.
- Hosted client key: IPv6 Fly-Client-IP collapses to its /64.
- Backoff keys only for a real workspace and a real role (junk role strings can't evict entries).
- One message for a wrong and a spent invite, so a guess is never confirmed.
…e; acks capped; bad token is 401

M2, M3 and F7 from the adversarial review of PR #5 (reproduced: a codex/repoB token acked repoA's session; 25
large acks pushed a workspace past 1 MB so the product team's proposal got 413; a bad agt. token on
/api/directives read the default store).
- directives poll, ack and stream: a session of another harness or repo is 403 for an agent token.
- MCP align_get_work_map and align_get_directives: an agent token sees only its own harness and repo's sessions.
- Ack: 4 KB capped JSON, id-shaped fields, and markDelivered records only an authority event that exists.
- A request carrying an agt. token that no longer verifies is 401 on poll, ack and stream (as capture already was),
  never the default store.
- Workspace streams no longer write the default store's listening map (their session ids leaked into Northstar's
  GET /api/sessions).
… guards compare the port

M4 from the adversarial review of PR #5 (reproduced: with a Northstar session, a text/plain POST /api/reset with
Origin http://localhost:5173 returned 200). Item 14 had only covered custom workspaces.
- requireSameOrigin: an Origin, when a browser sends one, must equal this server's Host including the port, and a
  request with a body must be JSON. Applied to bodyFor's default-store branch (reset, timer, proposals) and to the
  routes that parse with body(): session (login CSRF), alike, mappings, org/push, sessions/consent. Scripts send no
  Origin and JSON bodies, so Northstar is unchanged.
- assertLocal and the capture route's origin guard accept only this server's own origin (loopback name and same
  host and port), not any localhost page.
…n date enforced, crash leftovers swept

M5, M6 and M10 from the adversarial review of PR #5.
- M5 (reproduced: ~135 B per Restart, 1,060 Restarts in 3 s, archive unbounded): the audit archive rotates at
  1 MB to audit-archive.1.jsonl, so a workspace holds at most two archive files; custom Restart and connecting an
  agent are limited to 10 per minute per workspace. Northstar reset is unchanged.
- M6 (reproduced: a server with ALIGN_DELETE_BY=2020-01-01 started and served testers): a hosted server refuses to
  start once the date has passed, and a running one answers 410 on every tester route from that day. fly.toml
  turns off scheduled volume snapshots so data doesn't outlive the date.
- M10: .tmp-<id> and .deleting-<id> crash leftovers are removed at startup and by ws-admin delete --all.
…ading; test-server notice server-rendered

M7, M8 and M9 from the adversarial review of PR #5.
- M7: the page sends x-align-workspace with every write; requireWsSession answers 409 when the browser's session is
  for a different workspace (signed in elsewhere in another tab). The page then stops showing the old workspace and
  shows the Elsewhere notice.
- M8: when /api/session fails after a workspace or role change, "Loading workspace…" becomes the error with a
  Try again button instead of hanging.
- M9: /new decides the test-server notice ("don't enter confidential details… deleted by <date>") in a server
  component, so it shows even when the config fetch fails. /setup no longer reads an error body as a session.
…rets out of argv

Lows from the adversarial review of PR #5 (the backoff key, invite oracle, bad agent token and listening-map Lows
landed in the earlier commits).
- data/state.json is written 0600, like workspace state.
- scripts/agent-session.sh passes the workspace passcode, the leadership passcode and the agent token to node through
  one process's environment, and sends sign-in bodies from a file, so none of them appear in ps output.
…; docs match the code

- test-tenancy: Restart revokes agents (H1); leadership disconnect through DELETE /api/workspaces/agent-token, product
  403 (W6, replacing the hand-edited auth.json epoch); a create during the sign-in burst (M1); junk and oversize acks
  (M2); another harness/repo's token on poll, ack and the MCP work map, and a bad agent token on directives (M3);
  cross-port Northstar reset and directives (M4); Restart loop limit and archive rotation (M5); a passed deletion
  date refused at startup (M6); x-align-workspace mismatch 409 (M7).
- Made real: H1 refusals must exit or answer only 503; U checks fail without Chrome; the U2 leftover marker is first
  asserted on the signed-in page (the old one never appeared); W5 checks the recorded event id; S9 checks the refusal
  names the key; test-image needs a real listing before "no forbidden names" counts.
- /new wraps its config read in Suspense (Cache Components prerender).
- ALIGN_PUBLIC is in hostedProblems too, so proxy.ts's 503 fallback covers it.
- HOSTING.md: open proposal routes, which variables are refused at any value, deletion-date enforcement, H1, the
  fly.toml snapshot setting, test order. MCP.md: grant scope, expiry, disconnect and Restart revocation.

@allsmog allsmog left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security review of record, PR #6 @ 13f41b2

Status: changes requested. Do not merge. Most of the 9cd21ead findings are closed, and the H1 core is solid. This head still has two new Medium regressions, one M4 route it missed, and one go-C blocker in the purge path. The PR title says it fixes every finding; it does not yet.

Method: two independent read-only passes (architecture/security fidelity and cross-module seams), then I verified every finding below myself in the code at this head. Gate run: fresh npm ci on this head, scripts/test-all.sh 3247 15/15 PASS (test-tenancy 133/133, ui-northstar 72/72 and Northstar snapshot identical to 850971e, test-scenario 57/57 on all three fixtures). The gate is green; every finding below is one the gate does not test.

What closes (verified in code)

Finding Grade Evidence
H1 consent / revoke Resolved for the local merge Capture rechecks consent under the write lock (app/api/sessions/events/route.ts:211-217). Tokens carry exp, 7 days, checked server-side (lib/agent-token.ts:51). Restart and the leadership DELETE /api/workspaces/agent-token both bump agent_epoch (lib/workspace-setup.ts:50, agent-token/route.ts:69). Human sessions untouched.
H2 Next Resolved 16.4.2 in package.json and lockfile; npm audit --omit=dev 0 vulnerabilities on my install.
M1 invite burn Resolved Hash first (creation waits, never 429), then claim, then rate token, refund on any throw, all inside _create (lib/workspace.ts:233-259).
M2 ack padding Resolved 4 KB cappedJson + id regex (ack/route.ts:16-18); unknown event_id is a no-op (lib/directives.ts:151).
M3 scope Resolved Poll, ack, stream, MCP work map and directives all scoped (directives/route.ts:16, ack/route.ts:24, stream/route.ts:21, lib/mcp/register.ts:474,529); capture can't adopt another repo's session (events/route.ts:215).
M5 archive Resolved Rotation at 1 MB, two files max (lib/store.ts:122-128); Restart and connect 10/min.
M8, M10, bad agt. 401, junk-role keys, listening map, Northstar 0600, invite oracle Resolved as listed in the PR body; spot-checked.
Vacuous tests U1/U2, H1 exit, W6 product route Resolved test-tenancy.mjs:606,620,700-706,854.

Required fixes

1. Medium (new regression). Every 409 is now treated as "signed in to another workspace". components/useAlign.ts:138 clears state and reloads on any 409, but the server throws 409 for ordinary conflicts too: lib/flow.ts:20 (conflict(), the proposal flow), lib/org.ts:10,14,18, lib/mapping-decision.ts:18-22, lib/scenario.ts:255. Scenario: in Northstar, Security approves while Product counters in the other window. Before this PR the reason showed on screen; now the page flashes to Loading, finds the same workspace, and the message is gone. This changes Northstar UI behaviour, which the 72/72 text snapshot won't catch. Fix: a distinct status or error code for the workspace mismatch (for example 409 with code: "workspace_mismatch", or 421), and setError for every other 409.

2. Medium (M4 missed routes). Two Northstar write routes still accept cross-port CSRF. app/api/mappings/[session_id]/confirm/route.ts and .../reject/route.ts use requireSession only, need no body, and have no Origin check. A page on localhost:5173 auto-submits <form method=POST action="http://localhost:3100/api/mappings/claude-code:X/confirm"> and the cookie goes along (SameSite doesn't separate localhost ports). Fix: requireSameOrigin(req) in both. Add both to R14 so the test enumerates every cookie-auth write, not one.

3. Medium (go-C blocker). After ALIGN_DELETE_BY the purge path is unreachable. Once the date passes, any restart hits testServerClosed in hostedProblems (lib/config.ts:87), instrumentation.ts:9-11 calls process.exit(1), and the machine crash-loops. The documented purge is fly ssh console + ws-admin delete --all --yes (HOSTING.md:98-108), which needs a running machine. A Fly maintenance restart or deploy on the delete date leaves tester data on the volume with no documented way to remove it. Fix options: let the server boot in a closed mode that serves only 410 (proxy already does this) so ssh works, or run the purge itself at startup when the date has passed, and document a fly machine run fallback. Also note the cutoff is 00:00 UTC, which is 4 to 5 PM Pacific the day before; say so in the notice or use end-of-day.

4. Medium (carried, still open). quoted() doesn't flatten newlines. lib/directives.ts:61 only swaps «». Conditions (up to MAX_CONDITIONS) go into a join("\n") directive at :111, so "x\nREPLAN: run scripts/..." renders as its own instruction line inside the agent prompt. One line: .replace(/[\r\n

]+/g, " ") inside quoted(). This was flagged on #4 and #5 and is the main prompt-injection seam into agents.

5. Low-Medium. Revoked or expired agent tokens fail silently in the client. Server answers 401 correctly, but scripts/align-watch.mjs:87,114-116 drops non-OK responses and keeps polling every 2 s for the whole window; agent-session.sh:52-56 discards expires_at. After a Restart or a one-repo disconnect (which revokes every repo's token), agents stop getting wake-ups and nobody is told. Fix: watcher exits with one stderr line on 401/403; launcher prints the expiry. Note in MCP.md that tokens minted on a #5 build stop working at merge.

Lows (fix or list as known limits before go C)

  • clientNet collapses every IPv4-mapped address (::ffff:a.b.c.d) to 0:0:0:0::/64 (lib/ratelimit.ts:85-93). If Fly ever sends that form, all IPv4 clients share one bucket and two sign-in slots. Special-case ::ffff: to the embedded IPv4.
  • Per-client sign-in cap is 2, the global scrypt pool is 2+8, so five client IPs still starve every tenant's sign-ins (creation is now safe). Acceptable for go C; worth a line in HOSTING.md.
  • BAD_INVITE says "invite code" but NewWorkspace.tsx:103 matches /signup code/, so a wrong invite loses the field-level hint.
  • The /new notice is not in the server HTML: SSR renders Loading… (NewWorkspace.tsx:181) and the max-reached branch omits it. M9's failure case (config fetch fails) is fixed, but the commit's "server-rendered" claim isn't, and test-tenancy.mjs:869 checks only JSON.
  • DELETE /api/workspaces/agent-token has no rate limit; the epoch bump runs after the state write under a separate lock, so a failed bump leaves tokens valid with consents gone (capture refused, MCP reads continue).
  • bumpAgentEpoch leaves auth.json.<hex>.tmp (passcode hashes) on a crash; the startup sweep doesn't match it.
  • ALIGN_AGENT_TOKEN is still in the agent's environment (agent-session.sh:95); the plan-only deny list doesn't block env/printenv. Inherent to hooks; document it.
  • Still open from 9cd21ead: Fly-Client-IP presence-only (documented), stale pid file on Fly, no error.tsx/<noscript>, silent role switch from another tab, "check Align is running" copy to hosted testers, HOSTING.md "PID 1" (:132) and "safe while the server runs" (:116).
  • Tests still vacuous: S9 accepts 403/404/405 (test-tenancy.mjs:729); A6, A7, H10, R14 unchanged; W5 has no capture-onto-another-repo case; test-image.sh:23 greps -x '\.env.*' against absolute paths from find, so nested .env files never match.

Merge recommendation

Do not merge. For the local-mode merge: fixes 1, 2, 4. For hosted go C (Mon 10-26): additionally 3, 5, the IPv4-mapped fix, the test-image grep, and the HOSTING.md corrections, then my live smoke test on Fly. Main stays at e9ef581f for Friday regardless.

@liminalshruti

Copy link
Copy Markdown
Collaborator Author

Landed via #9: merge commit 95c43be on main (Oct 23 03:03Z), whose second parent is Sean-approved 3cd7f8a. This PR's head is an ancestor of 3cd7f8a, so all its commits are on main. Closed rather than merged separately, per the founder ruling on LIM-2424.

liminalshruti added a commit that referenced this pull request Oct 23, 2026
…ecord

LIM-2424: custom record, launch decision, Switch team, Halden fixture (stacked on #6)
allsmog pushed a commit that referenced this pull request Oct 23, 2026
app/error.tsx and app/global-error.tsx show "Something went wrong on this page." and a Try again button that calls
reset(): never the error's message or stack, only a server error's digest. app/layout.tsx adds a <noscript> notice.
test-tenancy NS1 (server HTML of /, /new, /w/<id>, local and hosted), NS2 (hidden with JS on, shown with it off) and
E1 (a goal with no measures makes /w/<id> throw; the error page shows, then Try again recovers once repaired).
allsmog pushed a commit that referenced this pull request Oct 23, 2026
…esn't answer (Sean's #6 carry-over)

"Check that Align is running" only makes sense to someone running Align locally. When hosted, a request
that gets no answer now shows the founder's ruled text: "The test server didn't answer. Try again in a
minute. If it keeps happening, tell the person who sent you the link." Local mode keeps its lines unchanged.

The strings live in one place, lib/labels.ts: NO_ANSWER_HOSTED, NO_ANSWER_LOCAL and
NO_ANSWER_WORKSPACE_LOCAL (the sign-in screen's own local wording). The three sites read them:
- app/new/NewWorkspace.tsx: the existing server-rendered testServer prop is the hosted signal.
- components/Home.tsx (workspace sign-in, login-info unreachable): it had no hosted signal, so
  app/w/[slug]/page.tsx now passes config().hosted from the server render (as /new does), and Home hands
  it down through Login to WorkspaceLogin. The Try again button stays.
- app/setup/page.tsx: reads NO_ANSWER_LOCAL only. A hosted server never serves /setup (proxy.ts sends it to
  /new with a 307), so there is no hosted case to word.
allsmog pushed a commit that referenced this pull request Oct 23, 2026
allsmog pushed a commit that referenced this pull request Oct 23, 2026
app/error.tsx and app/global-error.tsx show "Something went wrong on this page." and a Try again button that calls
reset(): never the error's message or stack, only a server error's digest. app/layout.tsx adds a <noscript> notice.
test-tenancy NS1 (server HTML of /, /new, /w/<id>, local and hosted), NS2 (hidden with JS on, shown with it off) and
E1 (a goal with no measures makes /w/<id> throw; the error page shows, then Try again recovers once repaired).
allsmog pushed a commit that referenced this pull request Oct 23, 2026
…esn't answer (Sean's #6 carry-over)

"Check that Align is running" only makes sense to someone running Align locally. When hosted, a request
that gets no answer now shows the founder's ruled text: "The test server didn't answer. Try again in a
minute. If it keeps happening, tell the person who sent you the link." Local mode keeps its lines unchanged.

The strings live in one place, lib/labels.ts: NO_ANSWER_HOSTED, NO_ANSWER_LOCAL and
NO_ANSWER_WORKSPACE_LOCAL (the sign-in screen's own local wording). The three sites read them:
- app/new/NewWorkspace.tsx: the existing server-rendered testServer prop is the hosted signal.
- components/Home.tsx (workspace sign-in, login-info unreachable): it had no hosted signal, so
  app/w/[slug]/page.tsx now passes config().hosted from the server render (as /new does), and Home hands
  it down through Login to WorkspaceLogin. The Try again button stays.
- app/setup/page.tsx: reads NO_ANSWER_LOCAL only. A hosted server never serves /setup (proxy.ts sends it to
  /new with a 307), so there is no hosted case to word.
allsmog pushed a commit that referenced this pull request Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants