Skip to content

Audit actor/instruction provenance across plugins #773

Description

@dcramer

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:

  1. 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).
  2. Mid-run steering: drained messages are injected via agent.steer(...) without rebinding credentials (packages/junior/src/chat/agent/index.ts:506-556).
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions