Skip to content

Say what started a run in the audit trail, not only whose authority it had - #386

Open
zopeVaibhav wants to merge 3 commits into
CopilotKit:mainfrom
zopeVaibhav:feat/unattended-audit-actor
Open

Say what started a run in the audit trail, not only whose authority it had#386
zopeVaibhav wants to merge 3 commits into
CopilotKit:mainfrom
zopeVaibhav:feat/unattended-audit-actor

Conversation

@zopeVaibhav

@zopeVaibhav zopeVaibhav commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

What this changes

Every audit row now names what caused it, alongside the actor it already recorded.

actor_user_id answers on whose authority an action was taken. A routine borrows that from its owner and a hop borrows it from whoever began the conversation, so that column alone cannot say whether the person was there. Two columns are added to audit_events:

initiator_kind initiator_id Meaning
person none Somebody was in the room. The default.
deployment none The deployment itself, at start-up or refusing a caller it could not identify.
routine the routine's id A schedule fired it, as its owner, with nobody there.
handoff the Bot that handed on Another Bot asked for this, on the person's behalf.

The value travels inside the signed run assertion, beside depth, for the reason depth does: a hop is one run on one pod handing to another on another, and the process that knows a routine began it is the one that claimed the routine, not the one writing the row. Anything holding the assertion holds the answer, so every writer a run reaches says the same thing without each being told separately: every mcp.call_* row through plugins/store.ts, a hop offered or refused at the desk in handoff.ts, a hop retried, delivered or failed in handoff-runner.ts, a Bot stopping to ask its person in escalation.ts, and a stream that stalls in stall-guard.ts. A Bot cannot relabel its own run, because the assertion is signed by the deployment, and a kind this deployment does not write is read back as a person rather than kept.

deployment exists so the column never overclaims. Four rows have no person behind them at any point: the boundary and isolation rows written at start-up, and the two refusals written when a caller cannot be identified at all. Without a truthful value those would take the person default and the screen would assert somebody was there.

The Audit screen gains a Started by column and a Nobody watching filter, which is initiatorKind=routine,handoff. That filter asks what ran on somebody's authority while they were away, so deployment is deliberately outside it.

Nothing about existing rows changes. initiator_kind is NOT NULL with a default of person, so every row already written reads as a person, which is what it was.

Closes #385.

What carries no initiator, and why that is correct

The computer.action_* rows. The computer tools are browser actions in app/src/lib/copilot/computer-tools.tsx, executed by the person's own session against routes guarded by requireUser, so a headless run has no way to drive the computer today. A row there is a person's because a person's browser wrote it. When a Bot is given its computer with nobody watching, the server-side actor that path constructs is where the initiator belongs, and it is one more field on a type this change already defines.

Every route behind a login that writes a row, credentials, sessions, component publishing, channels and routing, likewise keeps person, because the actor comes from the session and a person is there.

Where it runs

  • New state that outlives a request? Two columns on audit_events. No process-local state is added: the initiator is a value passed down the same call stack that already carries actorId, and written in the same INSERT the row was already doing.
  • What happens on the second replica? Nothing differs. A routine claimed by replica B writes initiator_kind = 'routine' because the value comes from the claimed row's routineId, not from anything held in the process that scheduled it. A hop delivered on a different replica from the one that offered it reads the initiator out of the signed assertion it was handed, so it writes what the offering replica would have. Every replica writes its own deployment rows at its own start-up, which is what those rows are for.
  • Anything serialised? No new serialisation. Both columns are written inside the existing single-statement insert, so there is no read-then-write to race. audit_events remains append-only by trigger, which is what stops the columns being re-attributed after the fact rather than any application-level check.
  • Anything fanned out to a browser? No. The Audit screen polls the existing /api/admin/audit-events endpoint, which returns the new fields because the reader selects the whole row.
  • New listener, port, or schedule? None.

Boundary and audit

  • Every acting call still goes through the gateway: resolve, decide, audit, then act. The call path is untouched, only what the audit step records is wider.
  • New refusals and new failures each write a row. Every mcp.call_rejected and mcp.call_failed now carries its initiator, the desk's agent.handoff_refused does, and the two unauthenticated boundary refusals say the deployment refused them rather than borrowing a person.
  • Nothing new is trusted from the client. initiator_kind is derived on the server at each write site and never accepted from a request body. Inside the assertion it is signed, so a Bot cannot present a run as a person's; on the way back in, a kind this deployment does not write reads as a person rather than being stored. routines.dispatch_refused is written before the body is parsed, so that refusal cannot be made to name a routine of the caller's choosing. The one client-supplied value is the read filter, validated against the four known kinds before it reaches SQL.

One choice worth naming: an unrecognised initiatorKind filter is ignored rather than matched, so it widens the result instead of emptying it. On an audit trail a filter that silently returns nothing reads as "nothing unattended happened", which is a false negative nobody can see. It has its own test.

Changelog

  • CHANGELOG.md under Unreleased, plus a "What started a run" section in docs/architecture.md and an update to docs/routines.md, which had this recorded as a known gap and now points at the answer.

Proof

Migration 0028 applied to a fresh local Postgres and checked with \d audit_events: both columns present, initiator_kind NOT NULL defaulting to 'person', audit_events_initiator_time_idx created, and both append-only triggers still bound. deployment needed no second migration, since the column is text.

Run end to end against a live deployment, not seeded rows. A person asked a Bot to create a routine through the browser. The worker later swept it, took the lease, dispatched it through /internal/routines/run, and the server ran a real turn that called a granted tool:

20:54:55  person   mcp.call_succeeded  routines/create_routine   initiator_id: none
21:10:15  routine  mcp.call_succeeded  routines/list_routines    initiator_id: routine_035f65b9-...
21:19:25  routine  mcp.call_succeeded  routines/list_routines    initiator_id: routine_035f65b9-...

Same Bot, same plugin. A person created the routine; the routine ran itself. Before this change those rows were identical on the actor column.

The first two rows a brand-new deployment writes, on a database created from nothing:

21:58:55  deployment  computer.isolation_loaded
21:58:55  deployment  computer.policy_loaded

Without the fourth kind, a fresh install would open its audit trail asserting a person acted before anyone had signed in.

The hop path, with the initiator carried through the signed assertion into the desk, on a real database across sixteen runs:

agent.handoff_offered  routine  routine-3f4e247f
agent.handoff_refused  routine  routine-36fb72bb

The filter, over the same data: initiatorKind=deployment returns only deployment rows, initiatorKind=person only person rows, and initiatorKind=routine,handoff returns routines and hops with zero deployment rows in it.

Twenty new tests, the ones that touch the column against a real database because the column, its default and the filter are all SQL:

  • audit-initiator.integration.test.ts (9): a person is the default and needs nothing passed; a routine names the routine; a hop names the Bot that handed on; the deployment acting as itself is not filed as a person; a boundary refusal is the deployment and is not swept up by Nobody watching; initiatorKind=routine,handoff returns both and only both; a single kind narrows; an unrecognised kind is ignored rather than returning nothing; an UPDATE re-attributing a stored row is refused by the append-only trigger.
  • plugin-store.integration.test.ts (2): a refusal raised inside callTool carries the routine that asked; a call with no initiator given is still filed as a person's.
  • agent-callback-token.test.ts (3): all four kinds survive a signed round trip; an assertion that says nothing reads as a person; a forged, malformed or unknown kind reads as a person rather than being kept.
  • agent-handoff.test.ts (3) and agent-escalation.test.ts (2): the offered, refused and escalated rows carry what the assertion says, and a run that says nothing leaves them filed as a person's.
  • stall-guard.test.ts (1): a stalled stream says what started its run.

Existing routine and handoff tests were extended to assert the value is carried rather than dropped in transit.

bun run typecheck     app, server, worker: clean
bun run lint          559 files, no findings
bun run format:check  clean
bun run test          2434 pass, 4 fail    (this branch)
bun run test          2414 pass, 2 fail    (bare origin/main, same database, same session)

The failures on both sides are in the two handoff integration files and reproduce identically on bare main: those files do not clean up the queue rows they create, so a database that has run them before fails them on the next run. Not this change, and not something CI sees, since it gets a fresh database per run.

One consequence of the trail being append-only: rows written before this change still read A person and always will. Only rows written from here on carry the true value.

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.

The audit trail cannot tell an unattended run from a person's own action

1 participant