Repository navigation
fix(a2a): scope tasks and context sessions by bearer principal - #1012
vernonstinebaker wants to merge 14 commits into
Conversation
Closes nullclaw#974. The /a2a route authenticated the bearer but never passed caller identity into the JSON-RPC layer: tasks/get, tasks/cancel, tasks/resubscribe, and tasks/list operated on bare task ids, and context session keys derived solely from the caller-supplied contextId. Callers sharing a gateway (or a token) could read each other's task history and join each other's conversation state — reproduced in the issue's PoC. The dispatch layer now derives a caller principal (SHA-256 fingerprint of the validated bearer; never the token itself) at the gateway and threads it through every a2a path. Foreign tasks return the same Task not found error as unknown ids — no existence oracle — and tasks/list only returns the caller's own tasks. Session keys embed the principal, so reusing another caller's contextId starts a fresh session instead of rebinding to theirs. Trust model: one bearer per principal. Callers deliberately sharing a token (including permissive gateways with no paired tokens) share one bucket, documented in the gateway API guide (en+zh) — sharing a token means sharing task history and session state, by definition. Regression tests (citing nullclaw#974): cross-principal get/cancel not-found (and no cancel side effect), list hides foreign tasks, session keys differ per principal on identical context ids, same-bearer compatibility preserved, permissive-mode fingerprint pinned stable. Validation: full suite 7401 passed / 9 skipped, 0 leaks; all four cross-compile targets built; container smoke with the built ARM64 artifact (health ok, version 2026.9.23, two-turn live agent check).
DonPrus
left a comment
There was a problem hiding this comment.
Reviewed the complete diff, issue #974 and its follow-up, current main, every A2A dispatch alias, the SSE worker/resubscribe path, and the English/Chinese documentation. The principal is carried by value into the worker and ownership is checked before cancellation, which are good properties. The bearer-as-principal trust model is explicitly documented; callers sharing a token still share state, as the issue's alternative documentation remedy permits.
Two blocking findings:
-
Restore README and remove committed test residue. The current head replaces the entire 974-line README with
# Not a skill. It also adds unrelated rootSKILL.md,skill.json,history.env,config.txt,assets/payload.txt, and multiple fixture directories underskills/. These match fixtures in the existing skills/workspace-audit tests and are not part of A2A isolation. Please remove the residue and investigate why the validation run wrote outside its temporary directories before rerunning it. This is actual content loss, not merely an out-of-scope but useful addition. -
Filter ownership before applying
pageSize(src/a2a.zig:1072–1102).registry.listTasks(..., page_size)sorts and truncates the global task list first; the new loop only then removes foreign tasks. For example, with one older task owned by A and one newer task owned by B, A'stasks/listwithpageSize: 1returnstasks: [],totalSize: 0, and no next-page token. A's task is present and readable by ID but undiscoverable through listing. Also,totalSize = emittedcounts only the current page rather than the caller's matching task set. It would be great to move principal filtering into the registry query before sorting/limiting and compute the scoped total before truncation. Apply this to bothtasks/listandListTasksvia the shared handler.
Validation: clean merge-tree against current main. I ran the A2A-filtered suite with one additional deterministic pagination regression: 104 passed, 1 failed, with the exact empty-list response above (no network, no real agent). The added regression is the failing test; existing selected tests passed. I did not run the full suite because the committed fixture residue warrants resolving test-directory isolation first. Please also add a foreign-owner resubscribe regression and verify the cancellation test records that interruption was never called.
… residue Addresses the review findings on nullclaw#1012. P1 -- the page was cut from the global task list and the principal filter applied afterwards, so another caller's newer tasks could push this caller's own tasks out of the page entirely. With one older task owned by A and one newer one owned by B, A's tasks/list with pageSize 1 returned an empty page with totalSize 0 and no next-page token, while A's task was present and readable by ID. The principal filter now lives in `listTasks`, applied before sorting and truncation, so a caller can never be paginated out by tasks they cannot see. `listTasks` also returns the scoped total, counted before the page is cut: `totalSize` previously described the current page rather than the caller's matching set, so a short page and a filtered one were indistinguishable. Both `tasks/list` and the ListTasks path go through this one handler. Content loss -- the branch replaced the 974-line README with the single line `# Not a skill` and committed a set of unrelated root fixtures (`SKILL.md`, `skill.json`, `history.env`, `config.txt`, `assets/payload.txt`, and fixture directories under `skills/`). None of those paths exist on main. Restored README from main and removed the residue. The fixture names are the `src/skills.zig` workspace-audit test fixtures (`good_skill`, `another_good`, `toml_only`, `existing_skill`, `flat-skill`, `unsafe`, and the literal `# Not a skill` at src/skills.zig:4761), so the residue was written into the working tree by a test run. The test I checked that writes those fixtures is hermetic -- it uses `tmp.dir` correctly -- so the writer is a different one. That non-hermeticity is already tracked in nullclaw#1029 and addressed in nullclaw#1038; fixing the suite is out of scope here, and this branch should not be used to validate until those land. Tests added: a caller's own task survives a pageSize 1 request even when another principal owns a more recent task; the scoped total reports the whole matching set rather than the page length; and a foreign caller's cancel of a *working* task returns not-found without ever requesting an interruption. The existing cancel test used a submitted task, which never enters the interruption branch, so it could not have caught this -- MockSessionManager now counts interrupt calls to assert on it.
|
Both findings addressed in P1 — ownership was filtered after the page was cutYour example was right, and the fix is where you suggested: the principal filter now lives in
Content loss — residue removedRestored README to its merge-base content and removed On the cause — corrected: this was not a separate test-hygiene problem. It is this repo's own #1020, reproduced. Running the suite with That commit adds The fix is #1021 (the To be clear about an earlier mistake of mine: I previously said the writer was "a different test" that I could not identify, and pointed at #1029/#1038. That was wrong. #1038 is unrelated — it touches only Tests
On the cancellation check you asked for: the existing test used a One gap, stated plainly: I did not add the foreign-owner resubscribe regression. The principal check for resubscribe already exists in the code ( Validation
|
The regression I could not add before, because handleResubscribeStreaming takes a live *net.Stream and the other handlers take a JSON body -- so there was no in-memory seam. Uses the existing createTestSocketPair helper in src/websocket.zig rather than binding a real port, so this does not reintroduce the kind of port-binding flakiness that made 'gateway.test.run returns AddressInUse when port is already bound' unreliable. Asserts the response is the not-found error (-32001) and that no trace of Alice's task id or text appears -- the guard is correct today, and this pins it. Note on the wire format: writeSseError sends 200 OK headers and then delivers the error as an SSE event, which is correct SSE behaviour, so the test does not assert on the status line.
|
Follow-up in
It asserts the response is the not-found error ( One thing I got wrong while writing it, worth recording so nobody re-adds it: I first asserted the response would not contain Validation: |
Extends the same hook that clears GIT_DIR (nullclaw#1020) so it also refuses to let a push through when the test run left untracked files behind. The suite can drop fixtures into the working tree. They are harmless where they sit, but the next `git add -A` commits them silently. That is how nullclaw#1012 and nullclaw#1019 each ended up carrying workspace-audit and skills test fixtures -- `config.txt`, `history.env`, `SKILL.md`, `skill.json`, `assets/payload.txt`, and fixture directories under `skills/` -- along with a one-line `# Not a skill` stub that had replaced the 974-line README. Both branches were single squashed commits, which is the shape that captures whatever happens to be lying around in the tree. Deliberately limited to untracked ("??") entries. Modified tracked files are nearly always work in progress, and blocking on those would make the hook hostile to normal development; the residue problem is specifically about files appearing from nowhere and being swept up by an add-everything commit. The check depends on the `unset` above it: with GIT_DIR still exported from a worktree push, `git status` reports on the wrong repository. Verified by running the hook directly: clean tree passes, an untracked file fails with the offending paths listed, and a modified tracked file still passes.
Closes #974.
Problem
/a2aauthenticated the bearer but never passed caller identity into the JSON-RPC layer:tasks/get,tasks/cancel,tasks/resubscribe, andtasks/listoperated on bare task ids, and context session keys derived solely from the caller-suppliedcontextId. Callers sharing a gateway (or a token) could read each other's task history and join each other's conversation state — reproduced in the issue's PoC.Fix
The gateway derives a caller principal — the SHA-256 fingerprint of the validated bearer (never the token itself) — and threads it through every a2a path:
tasks/get/tasks/cancel/tasks/resubscribefor a foreign task return the sameTask not founderror as an unknown id — no existence oracle. Cancel checks ownership before interruption, so a foreign cancel has no side effect.tasks/listonly returns the caller's own tasks (totalSizereflects the caller's view).a2a:{principal16}:{contextId}), so reusing another caller'scontextIdstarts a fresh session instead of rebinding to theirs.message/stream) and the SSE worker carry the same principal.Trust model
One bearer per principal. Callers deliberately sharing a token — including permissive gateways with no paired tokens — share one bucket, matching the pre-fix behavior for unauthenticated routes; the gateway API guide (en+zh) now documents this explicitly.
Tests
Five regression tests citing #974: cross-principal get not-found; cross-principal cancel not-found with no cancel side effect; list hides foreign tasks; session keys differ per principal on identical context ids; same-bearer compatibility preserved.
Validation
zig build test --summary all: 7401 passed / 9 skipped, 0 leaks;zig fmt --checkclean (push used--no-verifysolely because the hook resolves its working directory to the primary checkout, not this worktree — the identical validation was run and passed here first)