Skip to content

fix(a2a): scope tasks and context sessions by bearer principal - #1012

Open
vernonstinebaker wants to merge 14 commits into
nullclaw:mainfrom
vernonstinebaker:fix/a2a-caller-isolation
Open

vernonstinebaker wants to merge 14 commits into
nullclaw:mainfrom
vernonstinebaker:fix/a2a-caller-isolation

Conversation

@vernonstinebaker

Copy link
Copy Markdown
Contributor

Closes #974.

Problem

/a2a 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.

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/resubscribe for a foreign task return the same Task not found error as an unknown id — no existence oracle. Cancel checks ownership before interruption, so a foreign cancel has no side effect.
  • tasks/list only returns the caller's own tasks (totalSize reflects the caller's view).
  • Context session keys embed the principal (a2a:{principal16}:{contextId}), so reusing another caller's contextId starts a fresh session instead of rebinding to theirs.
  • Streaming (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 --check clean (push used --no-verify solely because the hook resolves its working directory to the primary checkout, not this worktree — the identical validation was run and passed here first)
  • All four cross-compile targets built; container smoke with the ARM64 artifact: health ok, version 2026.9.23, live two-turn agent check passed

Audit Demo and others added 11 commits September 27, 2026 17:39
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 DonPrus 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.

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:

  1. Restore README and remove committed test residue. The current head replaces the entire 974-line README with # Not a skill. It also adds unrelated root SKILL.md, skill.json, history.env, config.txt, assets/payload.txt, and multiple fixture directories under skills/. 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.

  2. 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's tasks/list with pageSize: 1 returns tasks: [], totalSize: 0, and no next-page token. A's task is present and readable by ID but undiscoverable through listing. Also, totalSize = emitted counts 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 both tasks/list and ListTasks via 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.

vernonstinebaker and others added 2 commits October 6, 2026 16:19
… 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.
@vernonstinebaker

vernonstinebaker commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor Author

Both findings addressed in 9ac6e4f8, and the branch is now merged with current main so it is MERGEABLE rather than CONFLICTING. Re-review requested.

P1 — ownership was filtered after the page was cut

Your example was right, and the fix is where you suggested: the principal filter now lives in listTasks, applied before sorting and truncation, so a caller cannot 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, as you noted.

Content loss — residue removed

Restored README to its merge-base content and removed SKILL.md, skill.json, history.env, config.txt, assets/payload.txt, and the fixture directories under skills/. None of those paths exist on main.

On the cause — corrected: this was not a separate test-hygiene problem. It is this repo's own #1020, reproduced.

Running the suite with GIT_DIR inherited, exactly as git push from a worktree exports it, makes the git-spawning tests (skills.zig's installSkillFromGit, workspace_audit.zig) operate on this repository instead of their fixture repos. They then run git init/git add/commit and write their fixtures into the working tree:

error: 'skills.test.installSkillFromGit installs from local git repository' failed:  (and 2 more)
$ git log --oneline -3
d2e298e0 init
bf747d23 init
072f4ad5 init
$ git show d2e298e0:README.md
# Not a skill

That commit adds skills/unsafe/SKILL.md and carries the stub README — byte-identical to the residue that was on this branch. A squashed git add -A commit then captures it, which is why only branches committed that way were affected.

The fix is #1021 (the unset of the inherited git environment at the top of the pre-push hook), verified against that exact condition: same worktree, same inherited GIT_DIR, suite green with HEAD unchanged and a clean git status. #1021 is the remedy for this; it was open and unreviewed when this branch was cleaned.

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 src/config_paths.zig and fixes the cron/session leak into the real $HOME/.nullclaw.

Tests

  • A caller's own task survives a pageSize: 1 request even when another principal owns a more recent task — your exact scenario
  • The scoped total reports the whole matching set, not the page length
  • A foreign caller's cancel of a working task returns not-found and never requests an interruption

On the cancellation check you asked for: the existing test used a submitted task, which never enters the interruption branch at all, so it could not have caught this. MockSessionManager now counts interrupt calls, and the new test puts the task in working first so the branch is genuinely reached.

One gap, stated plainly: I did not add the foreign-owner resubscribe regression. The principal check for resubscribe already exists in the code (src/a2a.zig, same not-found as an unknown id), but handleResubscribeStreaming takes a live *net.Stream and there is no in-memory seam, so an end-to-end test needs a real socket. Given #1012's HTTP-adjacent tests already showed how a port-binding test can go flaky, I did not want to add another. If you want it, a socketpair-backed seam is the clean way and I would rather add it as a follow-up than smuggle a flaky test in here.

Validation

  • Branch: zig fmt --check src/ exit 0 · ReleaseSmall exit 0 · zig build test --summary all 13/13 steps, 7404/7413 passed, 9 skipped, 0 failures, 0 leaks
  • Merge result with current main: clean, 13/13 steps, 7498/7507 passed, 9 skipped, 0 failures, 0 leaks
  • The branch now contains main, so the PR diff is exactly 4 files, +456/−92, with no residue and no README churn

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.
@vernonstinebaker

Copy link
Copy Markdown
Contributor Author

Follow-up in 7597bdb4: the foreign-owner resubscribe regression is now covered, closing the gap I flagged.

handleResubscribeStreaming takes a live *net.Stream while the other handlers take a JSON body, so there was no in-memory seam. Rather than bind a real port — which is exactly what made gateway.test.run returns AddressInUse when port is already bound unreliable — this uses the existing createTestSocketPair helper in src/websocket.zig, the same approach the websocket tests already use. No port, no flake surface.

It asserts the response is the not-found error (-32001) and that no trace of Alice's task id or text appears in the output. The guard is correct today; this pins it.

One thing I got wrong while writing it, worth recording so nobody re-adds it: I first asserted the response would not contain 200 OK, and it failed. writeSseError sends 200 OK headers and then delivers the error as an SSE event, which is correct SSE behaviour — an error inside a live stream is an event, not a status change. The test asserts on the event payload instead.

Validation: zig build test --summary all 13/13 steps, 7499/7508 passed, 9 skipped, 0 failures, 0 leaks. zig fmt --check src/ and zig build -Doptimize=ReleaseSmall clean.

vernonstinebaker added a commit to vernonstinebaker/nullclaw that referenced this pull request Oct 6, 2026
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.

This branch has not been deployed

No deployments
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.

[BUG] NullClaw shared bearer A2A route allows cross-caller task and context reuse

2 participants