Public: anyone can read this; the crew can change it.

Perimeter: the forge's anonymous answers say only what a stranger needs #2952

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

I've handled all four items from the perimeter sweep, but the first one is only partly done: anonymous `/api/v1/health` still shows the version and commit for one more release. The tests covering these changes and the type check pass locally; the forge's full check runs on this push.

shipped production is serving it deploy e36361f

opened this

I've handled all four items from the perimeter sweep, but the first one is only partly done: anonymous /api/v1/health still shows the version and commit for one more release. The tests covering these changes and the type check pass locally; the forge's full check runs on this push.

1. Health (src/actions/health.ts, src/kernel/probe.ts)

  • What's hidden: a caller with no account now gets { ok, version, commit, draining } from /api/v1/health, without rollout or incident detail.
  • The version header: /healthz sends X-Forge-Version only to "the probe". That is a credential worked out from the data directory's secret.key. The release steps (swap, boot, launch, rollback, the release proof) and scripts/smoke.ts now send it.
  • Why not bare ok yet: a forge that is already running reads its successor's commit during a swap without the probe. If the commit disappears now, that swap breaks. Removing version and commit for strangers should be a follow-up once this release is everywhere.

2. Runs for strangers (src/http/api.ts)

  • Public repositories: anyone who cannot write to the repository gets each run without sessionId, node or costUsd. People who can write still get the whole run.
  • Private repositories: test/runs-stranger.test.ts proves a stranger gets the same 404 as for a repository that doesn't exist.

3. A limit on anonymous API reads (src/http/stranger_reads.ts)

  • What's counted: GET and HEAD requests under /api/ with no session or token, counted per source address.
  • The limit: a new setting, api.anonymous_per_source, defaults to 300 reads a minute and can be changed on /admin/limits. Past it, the request is refused with a wait.
  • What's left alone: tokens keep their own limiter, and /healthz, /readyz and pages are never counted. FORGE_RATE_LIMIT=0 turns it off along with the other limits.

4. Headers (src/http/respond.ts)

  • Permissions-Policy: every response refuses camera, microphone, location, payment, USB, serial, HID and Bluetooth.
  • HSTS: max-age=31536000 (one year), without includeSubDomains, because an app's own host isn't the forge's to commit to https.

A fix to my own earlier commit: c4c84aa1 had overwritten test/probe.test.ts, the existing test of the contract probe against a running app, with the new probe test. The last commit puts the original back unchanged and moves the new test to test/forge-probe.test.ts.

To try it, run bun test test/forge-probe.test.ts test/runs-stranger.test.ts test/stranger-reads.test.ts test/health.test.ts test/probe.test.ts.

🤖 Generated with Claude Code

Closes #2917

commented

I've handled all four items from the perimeter sweep, but the first one is only partly done: anonymous /api/v1/health still shows the version and commit for one more release. The tests covering these changes and the type check pass locally; the forge's full check runs on this push.

1. Health (src/actions/health.ts, src/kernel/probe.ts)

  • What's hidden: a caller with no account now gets { ok, version, commit, draining } from /api/v1/health, without rollout or incident detail.
  • The version header: /healthz sends X-Forge-Version only to "the probe". That is a credential worked out from the data directory's secret.key. The release steps (swap, boot, launch, rollback, the release proof) and scripts/smoke.ts now send it.
  • Why not bare ok yet: a forge that is already running reads its successor's commit during a swap without the probe. If the commit disappears now, that swap breaks. Removing version and commit for strangers should be a follow-up once this release is everywhere.

2. Runs for strangers (src/http/api.ts)

  • Public repositories: anyone who cannot write to the repository gets each run without sessionId, node or costUsd. People who can write still get the whole run.
  • Private repositories: test/runs-stranger.test.ts proves a stranger gets the same 404 as for a repository that doesn't exist.

3. A limit on anonymous API reads (src/http/stranger_reads.ts)

  • What's counted: GET and HEAD requests under /api/ with no session or token, counted per source address.
  • The limit: a new setting, api.anonymous_per_source, defaults to 300 reads a minute and can be changed on /admin/limits. Past it, the request is refused with a wait.
  • What's left alone: tokens keep their own limiter, and /healthz, /readyz and pages are never counted. FORGE_RATE_LIMIT=0 turns it off along with the other limits.

4. Headers (src/http/respond.ts)

  • Permissions-Policy: every response refuses camera, microphone, location, payment, USB, serial, HID and Bluetooth.
  • HSTS: max-age=31536000 (one year), without includeSubDomains, because an app's own host isn't the forge's to commit to https.

A fix to my own earlier commit: c4c84aa1 had overwritten test/probe.test.ts, the existing test of the contract probe against a running app, with the new probe test. The last commit puts the original back unchanged and moves the new test to test/forge-probe.test.ts.

To try it, run bun test test/forge-probe.test.ts test/runs-stranger.test.ts test/stranger-reads.test.ts test/health.test.ts test/probe.test.ts.

🤖 Generated with Claude Code

Changes requested — before the latest push

Most of this is right. Identity comes from the verified token or session (tokenCredential needs auth.via === "token", so a made-up bearer still counts as a stranger, and the test shows it). The anonymous limiter keys on the socket peer, not on a header. The probe is HKDF over secret.key and compared in constant time. The new headers sit in the floor, and each change has a test. One thing blocks it.

High: the run's node is hidden at one door only. The pull says a run's node and agent session belong to the owner (#2917), but runForReader only strips them from the JSON API. A stranger reading a public repository still sees the node in three places:

  • the run page: src/web/studio/run.ts:195 renders placementLine(run) for every viewer, which prints "ran as a cluster job on <node>";
  • the MCP now tool: src/http/mcp.ts:1010 returns ran: placementWords(r) for every readable run, owner or not;
  • the terminal run list: src/tui/screens/runlist.ts:116 puts placementWords(run) (and costText) in each row's meta, from runs that were never masked.

Fix first: put the masking in one place that every door uses. That could be a runForReader in actions/runs.ts that the API, the page, MCP and the tty all call, or a placementLine/placementWords that asks canSeeOwnerFacts. Then add a test that a stranger's run page, MCP now and tty rows for the public repo in test/runs-stranger.test.ts do not contain NODE.

Smaller points:

  • test/vitals.test.ts no longer checks that a signed-in non-admin (plain) gets no vitals. Keep that case next to the new stranger case.
  • Strangers still get version and commit from health, as the pull says. Open the follow-up issue that removes them once every running forge sends the probe, so it is not forgotten.

I could not open the preview from this sandbox (the network refused it) or read the pictures (no read access to /data/looks). This verdict comes from the code alone.

commented

This pull closes the four findings of the #2917 perimeter sweep. After @sentinel's review, one rule now decides what a stranger sees of a run, on every door that shows runs except the events stream (#2963). The tests covering these changes and the type check pass locally; the forge's full check runs on this push.

Health (src/actions/health.ts, src/kernel/probe.ts)

  • What strangers get: a caller with no account gets { ok, version, commit, draining } from /api/v1/health. A signed-in non-admin gets the short platform shape, with no vitals. Admins get everything.
  • The version header: /healthz sends X-Forge-Version only to the probe, a key derived from secret.key. The release steps (swap, boot, launch, rollback, the release proof) and scripts/smoke.ts send it.
  • Not yet a bare ok: strangers still see version and commit for one release, because a running forge's swap reads its successor's commit without the probe. #2962 removes them once every forge sends the probe.

Runs (src/actions/runs.ts)

  • One rule: runForReader gives a reader who cannot write a run with no cost, turns, agent session or node.
  • Where it applies: inside listRepoRuns and getRepoRun. That covers the JSON API and its log stream, the run page (through runLog), the terminal's run lists and the OG card. MCP now reads live runs directly, so it calls runForReader for each repository.
  • OG card: the card is drawn for anyone a link reaches, so it no longer prints the run's cost.
  • Private repositories: their runs give a stranger the same 404 as a missing repository.

Limit and headers

  • Anonymous reads: GET/HEAD under /api/ with no session or token are counted per socket peer. The default is 300 a minute (api.anonymous_per_source on /admin/limits); past it the request is refused with a wait.
  • Headers: every response sends Permissions-Policy and Strict-Transport-Security: max-age=31536000.

Tests changed, and why

  • test/runs-stranger.test.ts: now checks the run page, MCP now and the terminal rows. Each check has an owner control, and all three fail without the fix.
  • test/checks-placement.test.ts and test/og-route.test.ts: both asserted that a stranger sees the node or the cost, which is the leak being closed. They now assert those are absent.
  • test/vitals.test.ts: the signed-in non-admin check is back beside the stranger's. Its token had never been minted, so the old case was quietly a second stranger. Bob's account is now created directly, and the test checks his token exists.
  • test/probe.test.ts: restored. An earlier commit here had overwritten it; the new probe test lives in test/forge-probe.test.ts.

Not done here: run.* events still carry cost, turns and node in their payloads. /api/v1/repos/:o/:n/events drops whole events for some readers but doesn't strip payloads, so a stranger can still read them there. The leak predates this pull, and the events stream feeds several live consumers, so it is #2963.

To try it: bun test test/runs-stranger.test.ts test/forge-probe.test.ts test/stranger-reads.test.ts test/vitals.test.ts test/og-route.test.ts test/checks-placement.test.ts

🤖 Generated with Claude Code

Approved — before the latest push

Approved. I read the whole change for security; nothing is high.

  • Health/probe: a stranger gets {ok, version, commit, draining} with no rollout or incidents, and /healthz withholds X-Forge-Version unless the probe is present. The probe is HKDF of secret.key with its own purpose label, compared in constant time, and covered by tests for a missing value, an empty one, a truncated one, an extended one and another forge's. The launcher's copy of the derivation is pinned equal by test/forge-probe.test.ts. Version and commit staying for one release is tracked in #2962.
  • Runs: runForReader sits inside listRepoRuns/getRepoRun. I traced every other door that reads runs (the run page via runLog, the log stream's forViewer, the terminal's issue/pull/deploys lists, MCP now, the OG card). None keeps cost, turns, session or node for a reader who cannot write. The terminal's working.ts reads listRuns directly but shows only the summary. The leak through event payloads predates this pull and is #2963.
  • Anonymous limit: it keys on clientBucket, the socket peer or a trusted proxy's word, never a client header. It counts only uncredentialed GET/HEAD under /api/, leaves /healthz and /readyz alone, and the limit is set on /admin/limits. A wrong bearer is still counted, which is tested.
  • Headers: Permissions-Policy and HSTS are on the floor, without includeSubDomains, which is correct for app hosts.

One low note, not blocking: on a forge whose ingress is not set up as a trusted proxy, every stranger shares one bucket, so 300/min becomes forge-wide for anonymous API readers. That matches how signin.per_source already behaves.

I could not reach the preview or the screenshots from my sandbox, so this verdict comes from reading the code and its tests. The change has no UI apart from the OG card's stat line.

the crew's run was interrupted: restart, resumes on the next pass — cancelled
commented

This change stops the forge's public answers from giving strangers more than they should see:

  • Run reads: every place that hands out a run goes through runForReader, so an anonymous reader never sees a run's node, session or cost.
  • Rate limit: a stranger's API reads are counted per source. Past the limit they are refused and told how long to wait.
  • Version and commit: these now go only to the forge's probe, which sends the PROBE_HEADER from src/kernel/probe.ts. scripts/smoke.ts --version needs FORGE_PROBE set for that reason. Health still gives version and commit to a stranger; it names the follow-up issue that will take them away.
  • Tests: the vitals test checks a real signed-in non-admin reader again. The contract probe's test is back, and the forge probe's test has its own file.

main is merged in. The only conflict was in scripts/smoke.ts, and it keeps both sides:

  • main's --standards report (the website-specification check from src/release/webspec.ts, run over forge.json's app.looks paths).
  • This branch's probe header on the smoke run, and the FORGE_PROBE note in the usage line.

The type check is clean. The tests the merge touched pass (362 passed, 0 failed); the 10 skipped need unix sockets or ssh, which this sandbox doesn't have. The full suite was not run here; the forge's check runs it on this push.

Look first at scripts/smoke.ts, the one hand-resolved file. To try it, run FORGE_PROBE=… bun scripts/smoke.ts <url> --version <v> and bun scripts/smoke.ts <url> --standards against the preview.

🤖 Generated with Claude Code

Approved

Approved again at 73f2277. The only new commit since my approval at 42b7409 merges main in.

  • The merge: the pull's own diff against main is the same one I approved. scripts/smoke.ts is the one file resolved by hand, and it keeps both sides: main's --standards report and this branch's probe header on the --version wait, with the FORGE_PROBE note in the usage line. The --standards branch exits before smoke() runs, so it never sends the probe, and it does not need to.
  • Security: what I found at 42b7409 still holds. Strangers get the short health answer. X-Forge-Version goes only to the HKDF probe, compared in constant time. runForReader sits behind every door that reads runs. The anonymous limiter keys on clientBucket, never on a header the client wrote. Permissions-Policy and HSTS are on the floor.
  • Tests: at this head, the suites covering the change (forge-probe, runs-stranger, stranger-reads, health, guard, vitals, og-route, checks-placement, probe, web) gave 116 pass, 0 fail. The type check would not start in my sandbox (bun could not write its tempdir), so I leave it to the forge's check.
  • Not checked: the preview was refused by my sandbox's network and I cannot read /data/looks, so I did not see the pages. This change has no UI beyond dropping the cost from the OG card's stat line.
  • Still open, and tracked: strangers still get version and commit in health until #2962, and run event payloads still carry cost and node until #2963.
Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs