Context
We merged the first targeted fix in #772. That PR fixed Slack thread prompt attribution by preserving the author on each current instruction, including mid-run steering, and by keeping passive skipped messages as recent thread context instead of concatenating them into the active instruction. That fix is model-facing only: the author survives as <current-instruction author_id=...> XML attributes in prompt text, which must never be treated as authority.
This issue tracks the broader version of the same bug class across runtime and plugins:
Credential actor, requester, and delegated credential subject must stay bound to the instruction that is allowed to cause side effects. Plugins and background tasks must not infer authority from aggregated prompt text, transcript text, metadata, display labels, or model-visible author labels.
The recurring failure mode is: Junior may authenticate or authorize as X while a prompt instruction, transcript fact, memory candidate, dispatch input, or plugin-owned task from Y is treated as executable authority.
Audit Findings (2026-07-06)
A code audit of every surface listed in the original issue narrowed the problem considerably.
Already sound (no work needed beyond tests)
The bottom of the stack correctly separates actor / credential subject / creator metadata:
- Credential issuance (
packages/junior/src/chat/plugins/credential-hooks.ts, packages/junior/src/chat/sandbox/egress/credentials.ts): identity inputs come exclusively from the signed/bound credential context (actor + optional bound credentialSubject). Prompt text, userText, and dispatch metadata never reach credential issuance. Request body text reaches only grant classification (read/write), never subject/actor selection.
- Dispatch (
packages/junior/src/chat/agent-dispatch/runner.ts): credentialContext = { actor, subject } is wired separately from prompt-visible dispatch.metadata; the synthetic user message author is system:<actor> attribution, not authority.
- Scheduler (
packages/junior-scheduler): createdBy is persisted as creator audit metadata; execution uses the system actor; credentialSubject is only issued for private direct conversations and passed separately from metadata.
Root cause
Identity is bound per-run, but instructions arrive per-message from multiple authors. Per-message author identity already exists at rest in two places — ConversationMessage.author (packages/junior/src/chat/state/conversation.ts:25) and the optional per-entry requester on pi_message session-log entries (packages/junior/src/chat/state/session-log.ts:34) — but it is flattened at every projection boundary:
project() collapses the session log to a single latest-wins requester (session-log.ts:495-526); projection_reset entries store raw PiMessage[] and lose per-entry identity entirely.
AgentTurnSessionRecord persists piMessages plus one run-level requester (packages/junior/src/chat/state/turn-session.ts:50).
- The plugin projection is built from those author-less
piMessages (packages/junior/src/chat/plugins/task-runner.ts:172), so pluginRunTranscriptEntrySchema exposes only role + text (packages/junior-plugin-api/src/tasks.ts:13).
By the time anything reaches a plugin, the only author signal left is prompt text.
Gap 1 — execution: Y's instruction can run under X's credentials
The credential context is bound once per run to the latest message's author (packages/junior/src/chat/runtime/reply-executor.ts:485-489) and governs all tool calls, MCP/plugin auth, and sandbox egress (packages/junior/src/chat/agent/tools.ts:119-234). Three paths admit another author's text into that run:
- Batched earlier mentions: earlier explicit mentions in a mailbox batch are appended into the latest author's turn as authored steering messages (
reply-executor.ts:1015-1027).
- Mid-run steering: drained messages are injected via
agent.steer(...) without rebinding credentials (packages/junior/src/chat/agent/index.ts:506-556).
- Continuation: resume replays the mixed
piMessages under the single persisted requester (packages/junior/src/chat/runtime/agent-continue-runner.ts:205-241).
Gap 2 — plugin projection: provenance dropped at the contract boundary
PluginRunContext exposes one run.requester plus an author-less transcript. Only three plugins consume these surfaces today (junior-memory, junior-github, junior-scheduler); the rest are declarative plugin.yaml only — so the blast radius of a contract change is small.
Gap 3 — memory: spec guarantee is unenforceable with today's data
specs/memory-plugin/policy.md already requires author == requester for personal-scope memories, but the data model cannot enforce it. Extraction concatenates all user-role transcript text into one anonymous evidence blob (packages/junior-memory/src/process-session.ts:124-128) and routes preference facts to a personal memory owned by run.requester unconditionally. Confirmed end-to-end failure: Bob posts "I prefer terse code reviews" in a thread where Alice is the run requester → stored as Alice's personal memory. The model cannot catch this: first-person phrasing with authorship stripped is indistinguishable from the requester speaking.
Proposed Invariant
One run, one actor. Every instruction executed in a run is authored by the run's credential actor. Everything else — other authors' messages, passive context, unauthored history — is evidence, never authority. Provenance is runtime-owned typed data and is never parsed back out of prompt text. An entry without provenance is unauthored context (fail closed).
Proposed Solution
A. Provenance primitive (pure data-flow, no behavior change)
The mechanism is half-built already:
- Always record the author on every user-role
pi_message the runtime appends (turn start, parked input, steering) — the slot exists at session-log.ts:34. Add an authority: "instruction" | "context" kind alongside it.
- Fix
project() to return per-entry provenance instead of latest-wins; make projection_reset entries carry per-message provenance (schema version bump; legacy entries tolerate absence).
- Persist aligned provenance in
AgentTurnSessionRecord next to piMessages.
- Legacy entries without provenance are unauthored context; no migration needed — old sessions simply cannot produce authority-sensitive output.
B. Plugin contract
- Add optional
author (reuse requesterSchema, ids not display names) and the authority kind to pluginRunTranscriptEntry message entries, plus a derived authoredByRequester convenience.
- Spec the contract in
specs/plugin-tasks.md: runtime context is authority; transcript is evidence; unauthored entries are context-only for authority-sensitive work.
C. Memory extraction: model proposes, runtime verifies
- Label the extraction transcript per author (model-visible labels are fine for classification; ownership stays deterministic).
- Extraction schema cites evidence message indices per proposed memory. The deterministic router writes a personal-scope memory only when every cited index is an instruction authored by
run.requester; otherwise downgrade to conversation scope or drop.
- Conversation-scoped public memories keep the broader public transcript; the existing no-third-party-personal-facts prompt rule stays.
D. Multi-actor authority: point-of-action checks (confused deputy)
(Revised 2026-07-07; supersedes the earlier queue-cross-actor-asks-as-own-turns recommendation.)
This bug class is the confused deputy problem (Hardy, 1988): Junior is a privileged deputy acting for multiple principals, and ambient run-level authority lets one principal's instruction spend another principal's credentials. The classic fix applies: do not carry ambient authority across a multi-author turn — check authority at the point of action, with an explicit principal. The agent-specific twist is that a tool call emerges from the model with no principal attached and prompt text must never supply one; per-message provenance (A) is the runtime-owned attribution signal, and human consent resolves what provenance cannot.
Recommended: lazy attribution at the credential boundary.
- Same-actor steering unchanged. Cross-actor messages — including active asks — keep flowing into active runs immediately as attributed context, so corrections ("stop, that's wrong") and contributions stay fast. No queue-behind-the-run latency.
- Deterministic floor: steered content from another author enters the run with
authority: "context", never instruction authority. The run's credential actor, credential subject, auth pauses, and OAuth flows never rebind mid-run.
- Every credentialed operation — reads included, since posting another principal's data into a shared thread is egress — gets a point-of-action check: if the run actor's grants cover it and provenance shows no cross-actor instruction-authority conflict in play, proceed. Otherwise fail closed into an auth pause addressed to the human whose authority is required.
- Consent is attribution. When attribution is ambiguous, the runtime never infers a principal from prompt/transcript text or a classifier — it asks. The consent action is human-verified attribution, which cannot be prompt-injected.
Alternatives considered and rejected:
- Queue cross-actor active asks as their own turns (the previous recommendation): strongest structural guarantee — misattribution becomes impossible and reply misattribution dies by construction — but every cross-actor ask pays active-run-length latency, and corrections queue behind the very run they are trying to stop. Rejected on product grounds.
- Upfront steering router: a model classifier deciding steer-vs-queue per cross-actor message is acceptable later as a UX optimization, but it must never gate authority — a classifier guarding a credential boundary is a prompt-injectable auth gate. Once the point-of-action check exists, the router adds no safety and is deferred.
- Per-instruction credential rebinding: a single Pi session with mixed authority is unauditable; mid-run leases make it worse.
- Read-only downgrade on cross-actor steer: conflates "who may steer" with "what tools may run".
Accepted nuance: cross-actor context can still shape tool calls executed under the active actor's own grants — inherent to shared threads, the same trust boundary as any prompt content. What this policy guarantees deterministically: auth pauses, OAuth flows, credential subjects, and memory/plugin authority always match the person whose instruction is being executed, and an operation needing another principal's authority fails closed to that principal's consent instead of executing under the run actor.
Known costs: occasional consent interrupts replace always-queue latency; a cross-actor ask that genuinely needs its author's credentials completes only after their consent (or a follow-up mention becoming their own turn). Reply phrasing misattribution (the model verbally attributing one participant's preference to another) remains a model-quality issue covered by eval rubrics — only queue-by-author eliminates it structurally, and we accept it.
E. Verification
- Integration tests: a cross-actor steered ask never rebinds the run's credential actor/subject and never triggers an auth pause or OAuth flow under the run actor; a credentialed operation attempted during a cross-actor instruction-authority conflict fails closed into a consent request addressed to the instruction's author; continuation preserves the deterministic floor.
- Eval: Alice and Bob provide conflicting first-person facts before memory processing runs; personal memory lands only for the authoring requester.
- Review guidance for plugin authors in the spec: runtime context is authority; transcript/prompt text is evidence only.
Sequencing
- Track 1 (safe, additive): A → B → C. No product-visible change; fixes the worst live bug (silent cross-user memory pollution).
- Track 2 (behavior change; recommendation revised 2026-07-07): D (point-of-action authority checks) + its tests. Closes the actual credential-misuse paths without the queue-by-author latency cost.
Follow-Up Work
Tracked follow-ups, in intended order (Track 1 + the actor terminology cutover are implemented on eval/multi-actor-provenance, pending merge):
Related
Context
We merged the first targeted fix in #772. That PR fixed Slack thread prompt attribution by preserving the author on each current instruction, including mid-run steering, and by keeping passive skipped messages as recent thread context instead of concatenating them into the active instruction. That fix is model-facing only: the author survives as
<current-instruction author_id=...>XML attributes in prompt text, which must never be treated as authority.This issue tracks the broader version of the same bug class across runtime and plugins:
The recurring failure mode is: Junior may authenticate or authorize as X while a prompt instruction, transcript fact, memory candidate, dispatch input, or plugin-owned task from Y is treated as executable authority.
Audit Findings (2026-07-06)
A code audit of every surface listed in the original issue narrowed the problem considerably.
Already sound (no work needed beyond tests)
The bottom of the stack correctly separates actor / credential subject / creator metadata:
packages/junior/src/chat/plugins/credential-hooks.ts,packages/junior/src/chat/sandbox/egress/credentials.ts): identity inputs come exclusively from the signed/bound credential context (actor+ optional boundcredentialSubject). Prompt text,userText, and dispatch metadata never reach credential issuance. Request body text reaches only grant classification (read/write), never subject/actor selection.packages/junior/src/chat/agent-dispatch/runner.ts):credentialContext = { actor, subject }is wired separately from prompt-visibledispatch.metadata; the synthetic user message author issystem:<actor>attribution, not authority.packages/junior-scheduler):createdByis persisted as creator audit metadata; execution uses the system actor;credentialSubjectis only issued for private direct conversations and passed separately from metadata.Root cause
Identity is bound per-run, but instructions arrive per-message from multiple authors. Per-message author identity already exists at rest in two places —
ConversationMessage.author(packages/junior/src/chat/state/conversation.ts:25) and the optional per-entryrequesteronpi_messagesession-log entries (packages/junior/src/chat/state/session-log.ts:34) — but it is flattened at every projection boundary:project()collapses the session log to a single latest-wins requester (session-log.ts:495-526);projection_resetentries store rawPiMessage[]and lose per-entry identity entirely.AgentTurnSessionRecordpersistspiMessagesplus one run-levelrequester(packages/junior/src/chat/state/turn-session.ts:50).piMessages(packages/junior/src/chat/plugins/task-runner.ts:172), sopluginRunTranscriptEntrySchemaexposes onlyrole+text(packages/junior-plugin-api/src/tasks.ts:13).By the time anything reaches a plugin, the only author signal left is prompt text.
Gap 1 — execution: Y's instruction can run under X's credentials
The credential context is bound once per run to the latest message's author (
packages/junior/src/chat/runtime/reply-executor.ts:485-489) and governs all tool calls, MCP/plugin auth, and sandbox egress (packages/junior/src/chat/agent/tools.ts:119-234). Three paths admit another author's text into that run:reply-executor.ts:1015-1027).agent.steer(...)without rebinding credentials (packages/junior/src/chat/agent/index.ts:506-556).piMessagesunder the single persisted requester (packages/junior/src/chat/runtime/agent-continue-runner.ts:205-241).Gap 2 — plugin projection: provenance dropped at the contract boundary
PluginRunContextexposes onerun.requesterplus an author-less transcript. Only three plugins consume these surfaces today (junior-memory,junior-github,junior-scheduler); the rest are declarativeplugin.yamlonly — so the blast radius of a contract change is small.Gap 3 — memory: spec guarantee is unenforceable with today's data
specs/memory-plugin/policy.mdalready requires author == requester for personal-scope memories, but the data model cannot enforce it. Extraction concatenates all user-role transcript text into one anonymous evidence blob (packages/junior-memory/src/process-session.ts:124-128) and routespreferencefacts to a personal memory owned byrun.requesterunconditionally. Confirmed end-to-end failure: Bob posts "I prefer terse code reviews" in a thread where Alice is the run requester → stored as Alice's personal memory. The model cannot catch this: first-person phrasing with authorship stripped is indistinguishable from the requester speaking.Proposed Invariant
Proposed Solution
A. Provenance primitive (pure data-flow, no behavior change)
The mechanism is half-built already:
pi_messagethe runtime appends (turn start, parked input, steering) — the slot exists atsession-log.ts:34. Add anauthority: "instruction" | "context"kind alongside it.project()to return per-entry provenance instead of latest-wins; makeprojection_resetentries carry per-message provenance (schema version bump; legacy entries tolerate absence).AgentTurnSessionRecordnext topiMessages.B. Plugin contract
author(reuserequesterSchema, ids not display names) and theauthoritykind topluginRunTranscriptEntrymessage entries, plus a derivedauthoredByRequesterconvenience.specs/plugin-tasks.md: runtime context is authority; transcript is evidence; unauthored entries are context-only for authority-sensitive work.C. Memory extraction: model proposes, runtime verifies
run.requester; otherwise downgrade to conversation scope or drop.D. Multi-actor authority: point-of-action checks (confused deputy)
(Revised 2026-07-07; supersedes the earlier queue-cross-actor-asks-as-own-turns recommendation.)
This bug class is the confused deputy problem (Hardy, 1988): Junior is a privileged deputy acting for multiple principals, and ambient run-level authority lets one principal's instruction spend another principal's credentials. The classic fix applies: do not carry ambient authority across a multi-author turn — check authority at the point of action, with an explicit principal. The agent-specific twist is that a tool call emerges from the model with no principal attached and prompt text must never supply one; per-message provenance (A) is the runtime-owned attribution signal, and human consent resolves what provenance cannot.
Recommended: lazy attribution at the credential boundary.
authority: "context", never instruction authority. The run's credential actor, credential subject, auth pauses, and OAuth flows never rebind mid-run.Alternatives considered and rejected:
Accepted nuance: cross-actor context can still shape tool calls executed under the active actor's own grants — inherent to shared threads, the same trust boundary as any prompt content. What this policy guarantees deterministically: auth pauses, OAuth flows, credential subjects, and memory/plugin authority always match the person whose instruction is being executed, and an operation needing another principal's authority fails closed to that principal's consent instead of executing under the run actor.
Known costs: occasional consent interrupts replace always-queue latency; a cross-actor ask that genuinely needs its author's credentials completes only after their consent (or a follow-up mention becoming their own turn). Reply phrasing misattribution (the model verbally attributing one participant's preference to another) remains a model-quality issue covered by eval rubrics — only queue-by-author eliminates it structurally, and we accept it.
E. Verification
Sequencing
Follow-Up Work
Tracked follow-ups, in intended order (Track 1 + the actor terminology cutover are implemented on
eval/multi-actor-provenance, pending merge):actorSchemasorun.actoris always presentCo-Authored-Byon commitsRelated
specs/identity.md,specs/credential-injection.md,specs/plugin-tasks.md,specs/plugin-prompt-hooks.md,specs/memory-plugin/security.md,specs/memory-plugin/policy.md