Skip to content

Commit d951e5e

Browse files
The /live token rode in URLs, the page was blind while the planner thought, and a finished run could not be watched (#89)
Three defects in the same file, fixed together because they share it. The token was accepted in the query string on every `/live` route, and a URL outlives the request: the uvicorn request line, the nginx access log, browser history, the referrer of anything the page opens. The index wrote the token into every link it rendered, so clicking a trace filed the secret a second time. `?token=` now reaches `/live/api/stream` alone — a browser `EventSource` cannot set a header, so that route has no alternative — and every other route refuses it with a 401 that says where to put the token instead. A browser gets a sign-in page rather than a bare 401 and trades the token for a cookie: a digest of it, `HttpOnly`, `SameSite=Strict`, scoped to `/live`, and ASCII, so a non-ASCII secret works where a latin-1 header could not carry it. The residual exposure — the SSE request line — is named in the cookbook beside `--live-token`, with what to scrub. The page rendered nothing for the whole planning phase, because it keys the graph off the `topology` event that only lands once a round is admitted and materialised — while `plan`, `admission` and `round` events were already on disk, thousands of tokens in. A run refused on every round has no topology at all and showed nothing from start to "finished". The snapshot now carries a `planning` block folded from those same events — no new trace events — and the page renders it: per round the proposal size, admission status, failed checks and rejection codes, planner tokens, and the loop's stop reason when it stopped without a graph. An open planning round also reads as active, since a planner mid-inference writes nothing for a minute at a time. A finished trace rendered instantly all-green, which makes the amber `running` styling unreachable for every run that is already over. `?replay=1` walks the recorded events in timestamp order and emits the snapshots the run would have sent, `&speed=N` divides the wall clock, and the whole replay is capped at 40 seconds so a long incident trace stays watchable. Frames come from the same snapshot code a live stream uses, pointed at a prefix of the file, and consult no clock — a trace replayed twice renders identically. With no parameter, nothing about the stream changed. The reader's confinement is untouched: `../`, `%2e%2e%2f`, absolute paths, NUL bytes and symlinked traces are the same 404s, and a hostile token is still a 401 rather than a crash. Fixes #41 Fixes #47 Fixes #48 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 62a1832 commit d951e5e

5 files changed

Lines changed: 897 additions & 39 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ Entries are newest-last within a release, matching the order they were written.
1414
- **the one edge-declaration path that still deferred its error.** `add_conditional_edge` passed the router and its mapping straight through to LangGraph, so a mapping pointing at a node nobody added was accepted, an empty mapping was accepted, and the first run to take that branch died on `self.ends[key]` — a bare `KeyError` raised from inside LangGraph's branch machinery, naming neither the graph, the source node, nor the router that produced the key. Everywhere else this kernel fails at declaration: an undeclared write raises at `add_node`, a write to a field the schema does not have raises at `add_node`, a cycle is refused at `compile()`. The mapping's targets were knowable all along. They are checked now, at `add_conditional_edge`, with an empty mapping refused and every unreachable target named alongside the key that leads to it; a router that annotates what it returns — a `Literal`, an `Enum` — has those members held against the mapping's keys, using the same hash lookup LangGraph will use, so the check predicts the failure rather than approximating it. A router that annotates nothing is still not second-guessed: predicting an arbitrary function's return value is not a check, and inventing a requirement would be worse than the gap. That last case is no longer a `KeyError`, though — the router is wrapped so an unmapped key raises `GraphRoutingError` naming the node, the key and the keys that were declared, which is what the rest of the kernel raises for a transition it cannot make. The wrapper keeps the router's name and annotations, because LangGraph names the branch after the one and infers the branch's input schema from the other.
1515
- a **reused `--run-id` silently welded two runs into one record.** Every executing command appends to its `--trace` file — by design, since `grapharc diff` reads two runs out of one file — and nothing checked whether the id the operator passed was already in there. Running the same `plan` twice with one `--trace`/`--run-id` pair produced a single "run" whose `metrics` summed both runs' tokens and node counts, whose `viz` drew the second path welded onto the end of the first, and whose `replay` reconstructed a chimera; the operator got no signal at any point, and the trace is documented as the record the metrics cannot disagree with. The file being appendable was never the defect — the id being reused was, so the guard sits at the start of the run rather than in the recorder: `plan`, `run` and `agent` (both executors) refuse an explicit `--run-id` that already has events in the target trace, with exit 2 naming the id, the count and the file, before a single event is written. Fail closed rather than auto-renaming, because a run id is the name an operator will look the run up under later and picking a different one silently is the same class of surprise. Generated ids are untouched — fresh by construction, so they pay for no scan — and different ids in one file stay exactly as they were.
1616
- the planner's system prompt **withheld the edge policy**, so a model had to learn it one refusal at a time. The prompt states the catalog, the START/END literals and the structural rules, and its own comments say why — "stating the rule up front is cheaper than three wasted rounds" — but the rule models actually trip over was the one it never stated. Observed with qwen3:8b against the incident registry: the goal said "find the cause and propose a fix", the policy denied `*->deploy`, and the planner proposed an edge into `deploy` in all three rounds (`edge_denied`; `edge_denied` + `cycle`; `edge_denied`) until the loop stopped `admission_refused` — about 3.5 minutes of local inference spent discovering one sentence, and a run that reads as a model failure when it is an information failure. The refusal came back every round and `edge_denied` names the check, not the rule, so "no edge may enter `deploy`, ever" was never on the page. `EdgePolicy.disclosure()` and `NodePolicy.disclosure()` now render a policy's deny rules as one line each (`edges into 'deploy' are denied by policy — do not propose them`), `PlannerNode(edge_policy=…, node_policy=…)` puts them directly under the catalog, and the shipped loop builders hand the planner the same policy *object* the checker holds, so the prompt cannot describe a policy the gate is not applying. Allow rules and the default are left out — they say what is permitted, which the catalog already covers — and so is `ask`, whose remedy is an approval rather than a different proposal. The refusal side is enriched to match: `EdgeRule` carries the `reason` `NodeRule` already had, `PolicyEngine.edge_policy()` compiles it out of the document instead of dropping it on the floor, and `policy/edge_denied` quotes it, so a planner reads why and not only what. **None of this is enforcement.** No check consults the disclosure, the admission gate is byte-identical, and a model that ignores what it was told is refused exactly as one that was never told — pinned by a test that compares the rejections of a disclosed and an undisclosed planner field by field, and by the shipped demo, whose scripted round 1 still proposes the denied deploy and is still refused.
17+
- the `/live` **token was accepted in the query string on every route**, and a URL is the one place a secret cannot be taken back from: the uvicorn request line, the nginx access log, browser history, and the referrer of anything the page opens. The index made it worse by writing the token into every link it rendered, so clicking a trace filed the secret in history a second time. It is refused off `/live/api/stream` now — that route keeps it because a browser `EventSource` cannot set a header and has no other way in — with a 401 whose reason says *where* to put the token rather than that it is wrong. A browser gets a sign-in page instead of a bare 401 and trades the token for a cookie: a SHA-256 digest of it rather than the token itself, `HttpOnly`, `SameSite=Strict`, scoped to `/live`, and always ASCII, so a non-ASCII secret survives the latin-1 header encoding that a `Bearer` header cannot. Links carry no token at all. The residual exposure — the SSE request line — is now named in the cookbook next to `--live-token`, with what to scrub. Every confinement the reader already enforced is untouched: `../`, `%2e%2e%2f`, absolute paths, NUL bytes and symlinked traces are the same 404s, and a hostile token is still a 401 rather than a crash. (#41)
18+
- the live page was **blind for the whole planning phase**, which is where a governed run spends its budget and does its refusing. `plan`, `admission` and `round` events were on disk — 2,081 tokens spent before any node ran, in the report — and the page rendered none of them, because it keys the graph off the `topology` event that only lands once a round is admitted and materialised. A run refused on every round produces no topology at all, so the most governance-relevant run there is showed nothing from start to "finished". The snapshot now carries a `planning` block folded from those same events (no new trace events): per round, the proposal size, the admission status, the checks that failed and the rejection codes, the planner tokens, and whether it executed; plus the loop's stop reason and detail when it stopped without a graph. The page renders it as a panel, and a round that has begun and not closed reads as *active* rather than idle — a planner mid-inference writes nothing for a minute at a time, which is exactly the "is it thinking or is it wedged?" the report describes. A run that never planned has no `planning` field and renders exactly as before. (#47)
19+
- a finished trace **rendered as a done deal**: instantly all-green, with the amber `running` styling unreachable for every run that is already over — and for any live run whose nodes finish between two SSE polls. `?replay=1` on the stream walks the recorded events in timestamp order and emits the snapshots the run would have sent, so a node is amber for its recorded window and green after; `&speed=N` divides the wall clock and the whole replay is capped at 40 seconds, so a 40-minute incident trace is watchable. Frames are rebuilt by the same snapshot code a live stream uses, pointed at a prefix of the file, and depend on no clock: a trace replayed twice renders identically. Without the parameter nothing changed. (#48)
1720

1821
## 0.1.3
1922

docs/cookbook/06-serving-and-ops.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1322,6 +1322,23 @@ status — each time the file grows. The server recomputes the snapshot;
13221322
the page only renders it. Add `&run=ID` to pin one run in a file that holds
13231323
several; without it the view follows the newest.
13241324

1325+
A planner run has no graph for as long as it takes the model to propose one,
1326+
so the snapshot also carries a `planning` block — round number, proposal size,
1327+
admitted or rejected with the checks that failed, planner tokens spent — and
1328+
the page renders it as a panel from the `plan`, `admission` and `round` events
1329+
already in the trace. A run refused on every round (`admission_refused`) never
1330+
produces a topology at all; it shows its rounds and its stop reason instead of
1331+
an empty page. A round that has begun and not closed also counts as activity,
1332+
because a planner mid-inference writes nothing for a minute at a time.
1333+
1334+
`GET /live/view?trace=REL&replay=1` replays a finished trace instead of
1335+
rendering its final state: the recorded events are walked in timestamp order
1336+
and each one emits the snapshot a live run would have sent, so nodes go amber
1337+
then green in the order and at the pace they really ran. `&speed=N` divides the
1338+
wall clock, and a whole replay is capped at 40 seconds however slow the
1339+
recording was, so yesterday's 40-minute incident trace is watchable. Without
1340+
the parameter nothing changes: one snapshot per file change, as before.
1341+
13251342
This composes with the Slack bot, which gives every tracing command a trace
13261343
path under its working directory: run `grapharc serve --live-root` over that
13271344
same directory, set `GRAPHARC_SLACK_LIVE_URL`, and the bot posts a
@@ -1338,7 +1355,26 @@ response; the exposure is what `viz` already prints. The bind stays
13381355
`127.0.0.1` unless you say otherwise; binding wider prints a warning, because
13391356
reachability is meant to come from a tunnel or tailnet in front, optionally
13401357
with `--live-token TOKEN` (or `GRAPHARC_LIVE_TOKEN`) required on every
1341-
`/live` request. The diagram renders with mermaid.js from a pinned CDN; with
1358+
`/live` request.
1359+
1360+
**Where that token is allowed to travel matters.** A URL is copied into places
1361+
with much weaker access control than the traces it protects: the uvicorn
1362+
request line, an nginx access log, browser history, and the referrer of
1363+
anything the page links out to. So the token goes in an
1364+
`Authorization: Bearer TOKEN` header, or in the cookie that `POST /live/auth`
1365+
sets when you paste it into the sign-in page a browser gets instead of a 401.
1366+
`?token=` is accepted on `/live/api/stream` and nowhere else — a browser
1367+
`EventSource` cannot set a header, so that one route has no alternative — and
1368+
any other `/live` route refuses a query-string token with a 401 that says so
1369+
rather than accepting the secret into your logs. The tradeoff that remains:
1370+
the SSE request line still carries the token, so if you terminate TLS at nginx
1371+
and log request URIs, scrub `token=` from that one path (or log
1372+
`$request_method $uri` rather than `$request`). The cookie is a digest of the
1373+
token, not the token, is `HttpOnly` and `SameSite=Strict`, and is scoped to
1374+
`/live`. Sign-in and the SSE exemption both apply only when a token is
1375+
configured at all; without one, nothing about `/live` is authenticated.
1376+
1377+
The diagram renders with mermaid.js from a pinned CDN; with
13421378
no CDN reachable the page falls back to the raw Mermaid source plus the same
13431379
mermaid.live fragment link the Slack bot posts.
13441380

docs/cookbook/07-slack.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,9 @@ contents — what the page shows is what `viz` and `metrics` already show.
182182
Reachability is deliberately your problem, not the bot's: the bot never opens
183183
a port (that is the whole point of Socket Mode), and `serve` still binds
184184
loopback by default. Put a tailnet or tunnel (Tailscale, cloudflared) in front
185-
for the person on the phone, and add `--live-token` if the URL is guessable.
185+
for the person on the phone, and add `--live-token` if the URL is guessable —
186+
the person then signs in once on the page rather than carrying the token in
187+
the link, which is what keeps it out of access logs and browser history.
186188
Details in [06-serving-and-ops.md](06-serving-and-ops.md).
187189

188190
## A `plan` that reads

0 commit comments

Comments
 (0)