feat(session): record invocation provenance on the root session's mount plan - #278
Draft
Brian Krabach (bkrabach) wants to merge 1 commit into
Draft
feat(session): record invocation provenance on the root session's mount plan#278Brian Krabach (bkrabach) wants to merge 1 commit into
Brian Krabach (bkrabach) wants to merge 1 commit into
Conversation
…nt plan
A script firing `amplifier run --mode single "..."` produces a `session:start`
record byte-identical (modulo ids and timestamps) to a human typing
interactively. Downstream forensic tooling therefore has to guess from
heuristics -- working-dir shape, prompt length, inter-prompt pacing -- and a
large share of sessions land in an honest but useless UNKNOWN.
This records what the CLI already knows at invocation time, at the one seam
that already exists for it: `_inject_observability_events()` mutates the root
mount plan immediately before `create_session()`, so a sibling
`_inject_invocation_metadata()` lands beside it under the same ordering
constraint. It rides the kernel's existing `session.metadata` passthrough
channel, so there is no new event, no schema change, and no change needed in
hooks-logging (`metadata` is not a promoted key, so it nests under `data`).
mount_plan["session"]["metadata"]["invocation"] = {
schema, mode, stdin_isatty, stdout_isatty,
launched_by, launched_by_session_id,
}
Design notes worth keeping:
- `mode` is the RESOLVED mode, not the raw `--mode` flag. The flag defaults to
"single", so recording it would label an interactive session "single".
Reaching interactive_chat() / execute_single() IS the resolution.
- `stdout_isatty` is the cheap discriminator: a harness that fakes tty-ish
pacing still usually redirects stdout.
- `launched_by_session_id` covers only the CROSS-PROCESS launcher, from an
AMPLIFIER_-prefixed env var. In-process lineage is already `parent_id`;
duplicating it would create a second source of truth. Unset => null, never a
fabricated id.
- NO argv. Prompt text, `--api-key ...`, and `"$(cat token)"` all land in argv,
and events.jsonl is long-lived and greppable. Raw config was already moved
off session:start onto the redacted session:config event; a free-text argv
field would reverse that.
- Merged under the `invocation` key only -- caller/bundle metadata is untouched.
- Self-report, not a security control. It defends against a harness that never
thought about provenance, not one that lies.
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Brian Krabach (bkrabach)
marked this pull request as draft
August 21, 2026 02:34
Collaborator
Author
|
PARKED at the maintainer's request (2026-08-20) — companion core PR (amplifier-core#103) is parked; this PR is inert-but-harmless without it but parks with it. Converted to draft. Tracking: microsoft/amplifier-bundle-attractor#308. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Record how a session was invoked on the root session's mount plan, so downstream tooling
can tell a human at a terminal from a script.
Surfaces at
session:startasmetadata.invocation; persists atdata.metadata.invocationinevents.jsonl.Why
A script firing
amplifier run --mode single "..."produces asession:startrecord that isbyte-identical — modulo ids and timestamps — to a human typing interactively. Forensic
consumers therefore have to guess from working-dir shape, prompt length, and inter-prompt
pacing. On one real corpus of 2,299 prompt-carrying root sessions, only 17.4% could be
called human with any confidence and 37.4% landed in an honest
UNKNOWN. Six recordedfields collapse most of that guesswork into a read.
Companion PR — merge order matters
microsoft/amplifier-core#103 lands first. The kernel's
session.metadatapassthroughchannel is specified, tested, and implemented in the Python kernel — but the Rust bindings
emit (which is what actually runs) drops it on
session:start/session:resume. Until thatparity fix ships, this change is inert but harmless: the mount plan carries the object,
the kernel ignores it, and the persisted payload is byte-identical to today. Verified below.
pyproject.tomlcurrently floatsamplifier-core>=1.5.3; the floor should be bumped towhatever release carries #103, but that is a separate one-line change once #103 is released.
How — the existing seam, not a new one
_inject_observability_events()already exists insession_runner.pywith exactly this job:mutate the root mount plan in place, after
inject_user_providers()and strictly beforecreate_session()._inject_invocation_metadata()lands beside it, under the same orderingconstraint, and is called on the next line. That is the whole integration.
No new event, no schema change, and zero changes needed in hooks-logging —
metadataisnot one of its promoted top-level keys, so it nests under
dataautomatically.Decisions worth reviewing
modeis the resolved mode.--modedefaults to"single", so recording the rawflag would label an interactive session
"single". Reachinginteractive_chat()/execute_single()is the resolution —run.pycollapses the flag, prompt presence, andpipe presence before dispatching. A caller that states no mode gets
"unknown", never aguess.
stdout_isattyis included and is the cheap discriminator: a harness that fakestty-ish pacing still almost always redirects stdout.
stdinalone is spoofed by a PTYwrapper; both being ttys is a much narrower claim.
os.isatty(0), notsys.stdin.isatty(), matchingdedicated_tty_input.py'sexisting reasoning that the fd is the thing that actually matters. Wrapped so a closed fd
reads as "not a tty" rather than taking the session down.
launched_by_session_idis cross-process only, sourced fromAMPLIFIER_LAUNCHED_BY_SESSION_ID. In-process lineage is already carried end-to-end byparent_id; duplicating it would create a second source of truth for the same fact. TheAMPLIFIER_prefix is already on foundation's subprocess env allowlist, so it propagatesto children for free. Unset ⇒
null, never a fabricated id.launched_byexists becauselaunched_by_session_id: nullis ambiguous — it meansboth "a human at a terminal" and "an agent that never set the env var". A separate coarse
enum lets each construction site say which, and lets consumers treat an unrecognized value
as UNKNOWN rather than as human.
invocationkey only. Any other metadata a bundle or caller alreadyplaced on the mount plan is left exactly as it was. Tested.
Explicitly NOT recorded: argv
No
argv, no command line, no environment dump — and there is a comment in the code sayingso, so a future "argv would be useful" change has to delete an explicit assertion rather than
quietly widen the record.
amplifier run "$(cat prod-token.txt)",--api-key …, and prompt text pasted on the commandline all land in argv, while
events.jsonlis a long-lived, greppable, exported artifact.This ecosystem has been moving away from that: raw config was already split off
session:startonto the separate, redactedsession:configevent. An unredactable free-textfield here would reverse that direction for no additional classification power.
Honest limit
This is a self-report, not a security control. A harness that sets
launched_by: "cli"and runs under a PTY will be classified as human — exactly as a caller that declines to pass
parent_idis believed today. It defends against the overwhelmingly common case (a harnessthat never thought about provenance) and claims nothing more. Please don't let a reader infer
a security property that isn't there.
Tests
tests/test_invocation_metadata.py— 23 new tests:execute_single()⇒"single",interactive_chat()⇒"chat"(asserted on the realSessionConfigthose functions build), plusNone ⇒ "unknown"null, empty ⇒null, set ⇒ recorded, prefix on the allowlistsession.metadatakeys preserved, existingsessionsectionkeys preserved, rest of the mount plan untouched, missing section created
invocationat the momentcreate_session()runs, not merely afterwards — injecting after that call would be asilent no-op
CI on this PR (
.github/workflows/ci.yml):teston {ubuntu, macos, windows} ×{py3.11, py3.12}, and
integrationon {ubuntu, macos}.Live proof
Scratch venv with both patches active (amplifier-core built from
microsoft/amplifier-core#103, this branch installed editable), one real
amplifier run --mode single "...". The persisted~/.amplifier/projects/<slug>/sessions/<id>/events.jsonlsession:startline:{"ts":"...","lvl":"INFO","schema":{"name":"amplifier.log","ver":"1.0.0"}, "event":"session:start","redaction":{"applied":true,"rules":["secrets","pii-basic"]}, "session_id":"a0122cbb-6438-4014-99cb-c63fefef9985", "data":{"metadata":{"invocation":{"launched_by":"cli","launched_by_session_id":null, "mode":"single","schema":1,"stdin_isatty":false,"stdout_isatty":false}},"parent_id":null}}Same stack, chat path (
--mode chatwith a piped initial prompt) — note"mode":"chat",i.e. the resolved mode, not the
--modedefault:{"ts":"...","event":"session:start","session_id":"b266f380-25e6-4fb8-b9d2-1990db8e4ba6", "data":{"metadata":{"invocation":{"launched_by":"cli","launched_by_session_id":null, "mode":"chat","schema":1,"stdin_isatty":false,"stdout_isatty":false}},"parent_id":null}}Negative control / "inert before core lands" — this branch unchanged, released
amplifier-core==1.6.0swapped back in, same command. The mount plan still carries theobject; the payload is byte-identical to today:
{"ts":"...","lvl":"INFO","schema":{"name":"amplifier.log","ver":"1.0.0"}, "event":"session:start","redaction":{"applied":true,"rules":["secrets","pii-basic"]}, "session_id":"bbbbd7c4-8a1b-4b26-ba73-edfcbf9dc577","data":{"parent_id":null}}Deliberately out of scope
launched_by: "spawn"/"subprocess"at the existing child-metadata write insession_spawner.pyis left as follow-up. Child sessions already carryparent_id, which isdefinitive lineage; root sessions are where the gap actually is. Adding it there is a small,
separable change once this field set is agreed.
Co-Authored-By: Amplifier 240397093+microsoft-amplifier@users.noreply.github.com