feat(mt#5086): Stamp session.started with the creating actor id for the peer advisory - #3718
Conversation
…peer advisory says whose session it is `session.start` now carries a server-injected, hidden `callerActorId` (added to `CALLER_ACTOR_ID_TOOL_NAMES`, the primitive the claim tools already use) and writes it as `actor` on the `session.started` event; the CLI path falls back to the harness env or writes null. `warn-peer-task-activity` relates a conversation-scoped writer id to the hook's own `session_id` and renders `this conversation` / `ANOTHER conversation — not yours, not your subagent's` / `comparison not made`, so a peer's session is no longer attributed from timing (the mt#5055 retraction: a principal-launched conversation's session was read as a subagent's). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VuuiE8iQYtmaajWTbJmhzK
…SC3), not only the not-compared branch Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VuuiE8iQYtmaajWTbJmhzK
Minsky Reviewer StatusVerdict: APPROVED — no blocking findings Commands
|
There was a problem hiding this comment.
Independent adversarial review (Chinese-wall)
Reviewer: minsky-reviewer[bot] via openai:gpt-5
Tier: 2
Requesting one fix before merge. Overall, the PR is well-shaped: it injects a hidden caller identity for session.start, stamps actor on session.started, and renders/labels it in warn-peer-task-activity; tests and docs were updated. However, there is a blocking risk that callerActorId may not be injected when the tool is invoked under the underscore alias: the Set in src/mcp/server.ts matches only the resolved dotted tool.name. Please either prove (by citing the registration) that session.start's registered .name is always dotted or add the underscore alias to the allowlist to eliminate the silent miss. Non-blocking: consider guarding the static domain import in the hook, clarify the case-insensitivity comment in relateSessionActor, explicitly check actor !== null when emitting, and add/point to a test that tools/list omits callerActorId for session.start. Once the registration/injection invariant is settled, this is ready.
Findings
- [BLOCKING] src/mcp/server.ts:257 — Risk of missed
callerActorIdinjection ifsession.startis registered under underscore alias; allowlist matches only dottedtool.name.
The allowlistCALLER_ACTOR_ID_TOOL_NAMES(src/mcp/server.ts:257-303) includesSESSION_START_TOOL_NAME = "session.start"and matches against the resolvedtool.nameonly. If any code path registers the tool with the underscore alias ("session_start") as the canonical name,tool.namewould be"session_start"and the injection would be skipped silently, breaking SC1 for MCP calls. Fix: either (a) cite and enforce that registration forsession.startalways uses the dotted name sotool.nameis dotted, or (b) include the underscore alias in the Set for this tool (and/or reintroduce alias mapping at the match site) to guarantee injection regardless of registration shape.
Documentation impact
- updated-in-pr — Documentation was updated in this PR alongside the code. The diff shows new/modified docs describing the new actor-labeling behavior for
warn-peer-task-activity:docs/architecture/hooks/warn-peer-task-activity.mdgained a section outlining the rendering cases; observer inventories were also updated in.claude/rules/hook-observers.md,.cursor/rules/hook-observers.mdc, and.minsky/rules/hook-observers.mdc. These updates cover the newly added behavior, so docs are updated-in-pr.
Affected: docs/architecture/hooks/warn-peer-task-activity.md, .claude/rules/hook-observers.md, .cursor/rules/hook-observers.mdc, .minsky/rules/hook-observers.mdc
…; pin the hidden param on the real params map
Cites the registration chain (addCommand({name: command.id}) → normalizeMethodName keeps
the dot → addTool maps both spellings to the same object) in a test that registers the
real session.start command through CommandMapper, calls it as `session_start`, and
asserts the handler received a resolved callerActorId rather than the caller's spoofed
one. Negative control: removing SESSION_START_TOOL_NAME from the set fails it with
"Expected: not \"spoofed-by-caller\"". Also: `collectMcpHiddenParamKeys` over the real
`sessionStartCommandParams`; explicit `actor !== null` at the emit; the case-fold
comment moved into `relateSessionActor`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VuuiE8iQYtmaajWTbJmhzK
There was a problem hiding this comment.
Independent adversarial review (Chinese-wall)
Reviewer: minsky-reviewer[bot] via openai:gpt-5
Tier: 2
Re-verified against the current spec and diff. The prior BLOCKING concern on name-resolution for callerActorId injection has been addressed: session.start is included in CALLER_ACTOR_ID_TOOL_NAMES, the CallTool path matches on the resolved tool.name, and a new test proves an alias session_start caller still receives a server-resolved callerActorId (spoofed values are overwritten). The hook now renders actor details with clear conversation-grain labeling, and the event emitter stamps actor correctly without fabricating ids. Docs were updated accordingly. I found no new critical issues introduced by the fix; one note records the positive regression test. Overall, the changes satisfy SC1–SC4 and are ready to merge.
Findings
- [NON-BLOCKING] src/mcp/server-tool-name-resolution.test.ts:146 — Good regression test proving alias callers still receive
callerActorIdinjection
The new test demonstrates thatsession.startis registered under its dotted id and that an aliassession_startcall is still matched against the resolvedtool.nameforCALLER_ACTOR_ID_TOOL_NAMES. This directly addresses the prior round's concern about name resolution. No action needed; noting as verification evidence.
Spec verification
| Criterion | Status | Evidence |
|---|---|---|
1. session.started rows emitted through the MCP server carry actor = the caller's resolved actor id (the same value tasks_claim returns as claimedBy for that caller). Rows emitted from the CLI carry whatever resolveCallerActorId(undefined) yields, or null — never a fabricated id. |
Met | src/adapters/shared/commands/session/basic-commands.ts:214-236 — emitSessionStartedEvent now accepts actor and spreads it only when non-null; 302-315 resolves via resolveCallerActorId(params.callerActorId as string | undefined) and passes it through. src/mcp/server.ts:1193-1210, 1245-1257 — CALLER_ACTOR_ID_TOOL_NAMES includes SESSION_START_TOOL_NAME, and the CallTool handler unconditionally overwrites callerActorId with the resolved agentId so spoofing is impossible. |
2. warn-peer-task-activity renders the actor beside each session.started line, and when it can resolve the reader's own actor id, labels the row another process or this process. When it cannot resolve its own id it prints the actor and says the comparison was not made — no bare "you may not have caused" for a row that names a foreign process. (Amended … labels are ANOTHER conversation / this conversation …) |
Met | .minsky/hooks/warn-peer-task-activity.ts:103-156 — adds actor?: string|null to TaskEventRow, SessionActorRelation type, and pure relateSessionActor. 268-311 — per-row rendering includes — ${describeSessionActor(...)}; 301-336 — adds paragraphs for another-conversation and not-compared. 411-422 — main entry wires input.session_id to callerConversationId for comparison. Mirror changes also present in .claude/hooks/warn-peer-task-activity.ts. |
3. The advisory's closing paragraph names the one cheap falsifier for the "my subagent did it" reading: the subagent transcript at <session-dir>/subagents/agent-<id>.jsonl. |
Met | .minsky/hooks/warn-peer-task-activity.ts:325-336 — appended closing lines explicitly direct the reader to the subagent transcript path as the falsifier. |
4. session.start is added to CALLER_ACTOR_ID_TOOL_NAMES with a mcpHidden: true callerActorId parameter, following tasks.claims.release's shape; the parameter is not advertised in tools/list. |
Met | src/mcp/server.ts:1174-1193 — SESSION_START_TOOL_NAME = "session.start" and included in CALLER_ACTOR_ID_TOOL_NAMES. src/adapters/shared/commands/session/session-parameters.ts:107-137 — callerActorId param declared with mcpHidden: true and cliHidden: true. src/mcp/mcp-hidden-params.test.ts:52-65 — test asserts only callerActorId is collected as mcpHidden for the real params map. src/mcp/server-tool-name-resolution.test.ts:146-219 — proves alias session_start still receives the injected callerActorId from the server (spoofed value overwritten). |
Documentation impact
- no-update-needed — Behavior change is internal/additive: stamping
actoronsession.startedand rendering it in the advisory. The PR updates the observer doc at docs/architecture/hooks/warn-peer-task-activity.md to describe the new labels and behavior, which matches the implementation. No other user-facing CLI or API contract changed beyond hidden parameter wiring; tools/list remains silent due to mcpHidden.
Summary
warn-peer-task-activitycould only say "asession.startedyou may not have caused", because the row carried no writer. On 2026-09-10 a conversation read exactly that as its own subagent's doing — the session had been started three minutes after it dispatched an opinion subagent — when a different, principal-launched conversation working a second succession package had created it. That attribution reached five durable records before the subagent's transcript falsified it (mt#5055, CLOSED with thePROBLEM STATEMENT FALSIFIEDbanner). This PR makes the inference unnecessary.Key Changes
session.startrecords who created the session. A hidden, server-injectedcallerActorIdparameter (cliHidden+mcpHidden, same shape astasks.claims.release's) is added tosessionStartCommandParams;session.startjoinsCALLER_ACTOR_ID_TOOL_NAMESinsrc/mcp/server.ts;emitSessionStartedEventwritesresolveCallerActorId(callerActorId)as the event'sactor, and null stays null — never a fabricated id.TaskEventRowgainsactor; a new purerelateSessionActorcompares a conversation-scoped writer id (…:conv:<uuid>) against the hook input'ssession_idanddecidePeerActivityrendersstarted by <actor> (this conversation)/(ANOTHER conversation — not yours, not your subagent's)/(comparison to your own id not made — see below)/no writer id on this row. When a row is another conversation's, a paragraph states why that rules out a subagent: a subagent's MCP calls carry its parent's id (resolveLiveConversationAgentIdtakes only the harness pid and the spawn-time env — verified in source during planning). When the id isproc:-scoped (the shim's fallback with no pid→conversation mapping), the hook prints it, says the comparison was not made, and points at the reader's ownclaimedBy. Every fire now closes by naming the falsifier for "my subagent did it":<session-dir>/subagents/agent-<id>.jsonl.task.status_changedrows stay unattributed; the advisory still never denies (mt#4788 owns the posture flip and is sequenced to consume this field).decidePeerActivitygains a trailing optional parameter, soturn-end-stale-state-assertion-scan's call is untouched. One static import into the hook —conversationIdFromAgentIdfromagent-identity/format.ts, a leaf module (format.ts→kinds.ts→ nothing), outside whatdomain-bootstrap.tslayer 1 guards.docs/architecture/hooks/warn-peer-task-activity.mdgains the rendering table and incident;hook-observers.mdcentry updated and recompiled (.claude/rules/hook-observers.md,.cursor/rules/hook-observers.mdcboth carry it — verified by grep, not by exit code).Testing
Execution evidence:
bun test --preload ./tests/setup.ts --timeout=15000 ./.minsky/hooks/warn-peer-task-activity.test.ts→ 27 pass / 0 fail (8 new cases undermt#5086 — a session.started row names its writer…).relateSessionActor pins each branch;AT2 — a row started by ANOTHER conversation is labelled so, and the subagent inference is ruled out(replays the 00:14Z row with conversatione7da3c7d's id against readerdd1a36b5);AT2 — a row started by THIS conversation is labelled so;AT2 — a row with actor null renders the unattributed form; plus theproc:"not compared" case and the no-caller-id case.the cwd-based self-suppression still wins over the actor label, and every pre-existing case (mt#4439 replay, PR feat(mt#4494): Give the parallel-work probe sequence a member that answers #3281 R1 window/suppression cases) unchanged and passing.tools/liston a server built from this branch:session.startparams aresessionId,task,description,branch,repo,quiet,noStatusUpdate,skipInstall,packageManager,recover;callerActorId advertised: false. Contract-level: thecollectMcpHiddenParamKeysmachinery is mt#4579's, exercised bysrc/mcp/mcp-hidden-params.test.ts.## Live verification: asession.startover MCP against this branch's server wrote asession.startedrow withactorpopulated with the id the server resolved for that client.actor: "unknown:hash:e988ed544601b00c"— the Layer-1 id for a raw-SDK client whose name is no known harness; the resolver's OUTPUT is what is stamped, whatever scope it yields) and the CLI-path null branch inemitSessionStartedEvent(...(actor ? { actor } : {})). SC2 — the AT2 label cases. SC3 —SC3 — every fire names the subagent transcript as the falsifier, even a plain status-change fire. SC4 —SESSION_START_TOOL_NAMEinCALLER_ACTOR_ID_TOOL_NAMES+ AT4.bun scripts/run-related-tests.tsover the four changed source files → 491 pass / 0 fail across 34 files plus 46 pass / 0 fail (src/mcp/server.test.ts,basic-commands.test.ts,claims-release.test.ts,session-parameter-family-parity.test.ts,hook-module-inventory.test.ts,interceptor-coordinates.test.ts,turn-end-stale-state-assertion-scan.test.tsamong them).src/mcp/server-tool-name-resolution.test.tsgainsmt#5086 — session.start is REGISTERED under its dotted id, so an alias caller still hits the callerActorId injection: it builds the realcreateSessionStartCommand, registers it throughCommandMapper.addCommand({ name: command.id }), assertstools.get("session_start").name === "session.start", then calls the tool assession_startover an in-memory client with a spoofedcallerActorIdand asserts the handler received a resolved id instead. The chain it cites:registerToolsCommandsWithMcp→addCommand({ name: command.id })(shared-command-integration.ts),normalizeMethodNamestrips only[^a-zA-Z0-9._-](a dot survives),addToolmaps both spellings to one object. Run: 5 pass / 0 fail.src/mcp/mcp-hidden-params.test.tsgains a case over the REALsessionStartCommandParams(collectMcpHiddenParamKeys→["callerActorId"]): 11 pass / 0 fail.validate_typecheck(session): 0 errors across 8 projects. ESLint on the changed source files: clean (aprefer-templateerror and two magic-string warnings were fixed before commit).Negative control — R1's alias-injection test: with
SESSION_START_TOOL_NAMEcommented out ofCALLER_ACTOR_ID_TOOL_NAMES, the same test fails withExpected: not "spoofed-by-caller"(the caller's value reached the handler); restored, 5 pass.Negative control — the advisory: with
.minsky/hooks/warn-peer-task-activity.tsreverted tomain(git stashof that one file) and the same peer-conversation row passed todecidePeerActivitywith the reader's conversation id, the pre-fix message contained neitherstarted bynorANOTHER conversation— it renderedsession.started 3m ago — session bc12bbc4-…and nothing about the writer. The new test file against the reverted hook: 0 pass / 1 fail (import ofrelateSessionActormissing).Live verification
Server built from this branch (
bun run <session>/src/cli.ts mcp start --http --port=39111, cwd = main workspace, throwaway static bearer token), driven with@modelcontextprotocol/sdk'sStreamableHTTPClientTransport(client namemt5086-at1-smoke):The scratch session was deleted afterwards (
session_delete, override reason recorded); mt#5055 stayed CLOSED (noStatusUpdate). The onesession.startedrow on mt#5055's ledger is the residue of this check.Deploy verification:
src/mcp/server.ts,basic-commands.tsandsession-parameters.tsare deploy surface (isDeploySurfaceFile→ true; the seven hook/rule/doc files → false). After merge I will rundeployment_wait-for-latestforminsky-mcpwithnotBefore= the merge timestamp andexpectCommitSha= the merge sha, readbuildIdentity, and assert the health body'sserviceisminsky-mcp. Local daemon: the nextsession_startfrom any conversation after the rebuild should write anactor; I will read one back viaevents_list.Sequencing
mt#4788 (flip this advisory to DENY) wants the field this PR adds; its spec now carries a sequencing note. PR #3412 (614 log-site rewrite, stale since 2026-09-04) touches
basic-commands.tsandserver.tsmechanically; recorded as proceed-acknowledged in mt#5086's planning audit — whoever moves second rebases, and the hunks do not intersect.🤖 Generated with Claude Code
https://claude.ai/code/session_01VuuiE8iQYtmaajWTbJmhzK