Skip to content

feat(session): record invocation provenance on the root session's mount plan - #278

Draft
Brian Krabach (bkrabach) wants to merge 1 commit into
mainfrom
feat/invocation-provenance-metadata
Draft

feat(session): record invocation provenance on the root session's mount plan#278
Brian Krabach (bkrabach) wants to merge 1 commit into
mainfrom
feat/invocation-provenance-metadata

Conversation

@bkrabach

Copy link
Copy Markdown
Collaborator

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.

mount_plan["session"]["metadata"]["invocation"] = {
  "schema": 1,                    // bump only on a breaking meaning change
  "mode": "single",               // RESOLVED mode: "chat" | "single"
  "stdin_isatty": false,          // os.isatty(0)
  "stdout_isatty": false,         // os.isatty(1)
  "launched_by": "cli",           // which construction site built this
  "launched_by_session_id": null  // cross-PROCESS launcher, or null
}

Surfaces at session:start as metadata.invocation; persists at
data.metadata.invocation in events.jsonl.

Why

A script firing amplifier run --mode single "..." produces a session:start record that is
byte-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 recorded
fields collapse most of that guesswork into a read.

Companion PR — merge order matters

microsoft/amplifier-core#103 lands first. The kernel's session.metadata passthrough
channel 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 that
parity 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.toml currently floats amplifier-core>=1.5.3; the floor should be bumped to
whatever 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 in session_runner.py with exactly this job:
mutate the root mount plan in place, after inject_user_providers() and strictly before
create_session(). _inject_invocation_metadata() lands beside it, under the same ordering
constraint, and is called on the next line. That is the whole integration.

No new event, no schema change, and zero changes needed in hooks-loggingmetadata is
not one of its promoted top-level keys, so it nests under data automatically.

Decisions worth reviewing

  • mode is the resolved mode. --mode defaults to "single", so recording the raw
    flag would label an interactive session "single". Reaching interactive_chat() /
    execute_single() is the resolution — run.py collapses the flag, prompt presence, and
    pipe presence before dispatching. A caller that states no mode gets "unknown", never a
    guess.
  • stdout_isatty is included and is the cheap discriminator: a harness that fakes
    tty-ish pacing still almost always redirects stdout. stdin alone is spoofed by a PTY
    wrapper; both being ttys is a much narrower claim.
  • fd-level os.isatty(0), not sys.stdin.isatty(), matching dedicated_tty_input.py's
    existing 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_id is cross-process only, sourced from
    AMPLIFIER_LAUNCHED_BY_SESSION_ID. In-process lineage is already carried end-to-end by
    parent_id; duplicating it would create a second source of truth for the same fact. The
    AMPLIFIER_ prefix is already on foundation's subprocess env allowlist, so it propagates
    to children for free. Unset ⇒ null, never a fabricated id.
  • launched_by exists because launched_by_session_id: null is ambiguous — it means
    both "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.
  • Merged under the invocation key only. Any other metadata a bundle or caller already
    placed 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 saying
so, 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 command
line all land in argv, while events.jsonl is a long-lived, greppable, exported artifact.
This ecosystem has been moving away from that: raw config was already split off
session:start onto the separate, redacted session:config event. An unredactable free-text
field 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_id is believed today. It defends against the overwhelmingly common case (a harness
that 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:

  • exact field set; argv/command-line/env keys explicitly asserted absent
  • resolved mode per entry point: execute_single()"single", interactive_chat()
    "chat" (asserted on the real SessionConfig those functions build), plus None ⇒ "unknown"
  • full isatty matrix, mocked both ways (4 combinations) + closed-fd survival
  • launcher env var: unset ⇒ null, empty ⇒ null, set ⇒ recorded, prefix on the allowlist
  • merge semantics: existing session.metadata keys preserved, existing session section
    keys preserved, rest of the mount plan untouched, missing section created
  • ordering: the mount plan is asserted to carry invocation at the moment
    create_session() runs
    , not merely afterwards — injecting after that call would be a
    silent no-op
uv run pytest -q                  1456 passed, 1 skipped, 13 deselected, 1 xfailed
uv run pytest -m integration -q     13 passed, 1458 deselected

CI on this PR (.github/workflows/ci.yml): test on {ubuntu, macos, windows} ×
{py3.11, py3.12}, and integration on {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.jsonl session:start line:

{"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 chat with a piped initial prompt) — note "mode":"chat",
i.e. the resolved mode, not the --mode default:

{"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.0 swapped back in, same command. The mount plan still carries the
object; 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 in
session_spawner.py is left as follow-up. Child sessions already carry parent_id, which is
definitive 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

…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>
@bkrabach

Copy link
Copy Markdown
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.

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.

2 participants