Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/captures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ No prompt text, no tool input, no tool output, no file path. `PreToolUse` record
| `opencode/plugin-permission-1.18.20-macos.jsonl` | Whether OpenCode's `permission.asked` / `permission.replied` pair carries a session id that joins to the row `collectors/opencode.py` already keys on. What is distinctive is where the join lives: the resolve event carries the session id inline, so a reader needs nothing but the event itself to close the request it opened. The `copilot/permission-events` row above records the other measured resolve event, and that one joins on `requestId` and takes its session identity from the store path. Carries no cross-harness tally on purpose; sibling probes landed the same day and DEC-2 owns the count. opencode 1.18.20, macOS, four interactive TUI sessions driven through `tmux`, one arm apiece, from a scratch project directory outside this repository, with `XDG_DATA_HOME` redirected so no session reached the user's store or dashboard and `CLAUDE_PLUGIN_ROOT`/`DROID_PLUGIN_ROOT` unset for every child. The recorder is **one plain file** under `.opencode/plugin/`, subscribed to the plugin API's `event` hook: the issue's premise that this needs an npm plugin is wrong, and that matters, because npm is broken on this machine and would otherwise have blocked the probe outright. The gate was raised through `OPENCODE_PERMISSION` in the environment rather than a config file, so nothing was written outside the probe's own tree, and the model was a free OpenCode Zen one, so no credential was configured or used. **Positive on the whole question.** `permission.asked` carries `always`, `id`, `metadata`, `patterns`, `permission`, `sessionID` and `tool`; `permission.replied` carries `sessionID`, `requestID` and `reply`; the `requestID` is the `id` of the ask; and in all three gated arms the `sessionID` is exactly a `session.id` row in `opencode.db` — a verdict taken by re-hashing the store's own rows with the probe's salt, so no id was written down anywhere. That is `input_requested`/`input_resolved` with no dwell, no expiry and no inference. All three `reply` spellings were driven, one arm apiece: `once`, `reject`, and `always`, which raises a second dialog saying it holds only until OpenCode restarts. The gates stood 130.4 s, 75.6 s and 35.7 s, and the fourth arm is the control that makes the negatives below worth anything — same recorder, same prompt, same tool, with bash allowed rather than asked: the tool ran and no permission event was produced. **Negative on every store-only route, each taken while the dialog was on screen.** The `permission` table holds project-scoped saved approvals and stayed at zero rows through all four arms, `Allow always` included; the durable `event` table carries only `session.created`, `session.updated`, `message.updated` and `message.part.updated`; and no `session.status` event is delivered between the ask and the reply in any of the three gated arms — the grant and always arms' whole status sequences are the ungated control's exactly, and the reject arm's differs only by a `retry` — so the status lane never announces the wait and there is no during-gate status reading to poll. The file records that absence as a field rather than leaving it to be inferred from a sequence with no timestamps on it. Nothing at all reaches the plugin between the ask and the reply but a single `session.updated`, and the store barely moves: its counts advance at most once inside the gate window and then hold — two distinct (message, part, event) triples in the grant and always arms, one in the reject arm — and they advance again the moment the answer lands, which is the recorded half of the reader-liveness control. Where inside the window that one advance fell was not kept, and no before-the-ask reading was recorded, so the file lists both as unmeasured rather than reading a freeze into them. Two findings belong elsewhere and are recorded here because this is where they were seen. `session_message`, the table the collector's turn scan reads, is **empty** on 1.18.20 across the nine `session` rows the probe's own store holds — the rows are in `message` and `part`; eleven sessions were driven against that store and why two of them left no `session` row is unexplained and recorded as such — so the `SELECT DISTINCT type` the issue asked for has nothing to answer with, and no OpenCode row can show a turn. And an eight-character session-id prefix does not discriminate here: nine store sessions collapse to three prefixes, so a Claude-shaped truncation would collide. The instrument carries its own control because it turned out to need one: an earlier build read its salt from a module const, got some other string back under OpenCode's loader, and reported every identity verdict as false. The file records the digest of a known literal so a reader can see the hashing behind the verdicts was sound. |
| `goose/tool-confirmation-1.47.0-macos.jsonl` | Whether Goose writes a `toolConfirmation` into `messages.content_json`, which is what decides between a nearly free passive needs-input and an honest blank. goose 1.47.0, macOS, six interactive sessions driven through `tmux`, one arm apiece, against a scratch `XDG_CONFIG_HOME`, `XDG_DATA_HOME` and `XDG_STATE_HOME`, which kept every session's configuration, store and session log out of the user's home — `~/.config/goose` and `~/.local/share/goose` are still absent after the run — and against a loopback OpenAI-compatible stand-in handing the session exactly one shell tool call, on the precedent the Gemini and Copilot captures set, since the path under test is client-side. The overrides did not cover every `goose` write: `~/.local/state/goose/logs/cli/` holds six per-invocation CLI logs from runs made without them, five of them empty and one holding a single `CLI command executed` line, five written inside the probe window and one predating it. That directory is the only thing this probe left in the user's home, and it is named here so the next probe knows `XDG_STATE_HOME` does not cover it. `GOOSE_MODE` was `approve`, read back off the session's own `/status` and off `sessions.goose_mode` rather than assumed. **Negative, and the control is inside every arm**: while the confirmation dialog stood — 93 s approved, 169 s denied, 137 s cancelled, each evidenced by the pane — the newest `content_json` held `text` parts and nothing else, no row was added at all, and a sweep of every text column of every table found none of `toolConfirmation`, `actionRequired`, `confirmationId` or `frontendToolRequest`. Answering is what writes: `toolRequest` and `toolResponse` arrive together once the key is pressed, backdated to the turn start, and that is the positive control separating a blank store from a blind reader. Nor is the blank a stale snapshot: the store runs in `wal` mode and every read opened a fresh `mode=ro` connection with `immutable` unset, so a row committed while the gate stood would have been visible to the reader that found none. A **cancel writes nothing at all**, so an abandoned call leaves no trace in either direction. The `actionRequired` variant with its `toolConfirmation` `actionType` is real — it sits in the binary's message-content enum beside `toolRequest` — and the file records that as read in the binary rather than run, because no arm put one on disk. Two readings from the **shipped collector** say what the blank costs: 48.8 s into a standing gate the row reads `working`/`generating…`, and 125.6 s in it reads `idle`/`awaiting your message`, because `sessions.updated_at` is bumped once as the gate opens and then sits frozen — 149 s between the two polled values, a window the file does not tie to one arm, because it runs longer than the 137 s gate the readings beside it name — so a gate past the 90 s working threshold is indistinguishable from a session nobody is using. The hook route was measured in the same rig and does not reach the gate either: with the probe plugin registered, `SessionStart` and `UserPromptSubmit` fire, then nothing for the 83.2 s between the prompt and the answer — the window that holds the standing dialog, and the model's generation before it, since nothing timed the dialog itself in that arm — after which `PreToolUse`, `BeforeShellExecution`, `PostToolUse`, `AfterShellExecution` and `Stop` all land inside 0.71 s. **Positive on identity**: the hook's `session_id` is the whole 10-character `sessions.id` the collector keys on, untruncated, checked by digest against the store row rather than by eye. Three registration arms are what make that negative safe, and they carry a warning of their own — a wrapped `hooks.json` logs `Loaded plugin hooks` with `rule_count` 11, an unparseable one logs `Failed to load plugin hooks; skipping` naming the plugin and the file, and a **flattened one, events hoisted to the top level, the exact shape that voided the first Antigravity capture, logs nothing and registers nothing**, so on Goose a malformed probe is indistinguishable from a harness with no hooks. Discovery is `$HOME`-relative, which is how the probe kept its plugin out of the user's home: the plugin lived under a scratch `HOME`, was named `cargento-probe-goose` rather than `cargento`, passed `goose` as its own harness argument, and posted to no dashboard at all, while the two arms run without it loaded zero rules. `~/.agents/plugins` was absent before this run and is absent after, and that absence is the only thing between Goose and the mis-route into `/api/events/claude` that the containment note describes, since `validate_hook_vocabulary` is keyed by file path and cannot say that Claude's file is also read by Goose. That mis-route is reasoned rather than run — no arm pointed Goose at the shipped file — but the registration arms are what it rests on: `cargento/hooks/hooks.json` carries the single top-level key `hooks`, which is the wrapped shape Goose loads rather than the flattened shape it ignores, and five of its ten event names are among the eleven Goose registered. |
| `droid/notification-0.202.0-macos.jsonl` | Whether Droid's `Notification` hook fires with the `notification_type` vocabulary `notifications.py` already implements, and whether `session_id` is what `collectors/droid.py` keys on. droid 0.202.0, macOS, three sessions driven from a scratch directory outside the checkout with `CLAUDE_PLUGIN_ROOT` and `DROID_PLUGIN_ROOT` unset — two of them with the recorder registered, and **every hook execution in the file is from the non-interactive `droid exec` path**, which Droid's own log stamps `droidMode=non-interactive-cli`. The interactive `terminal-ui` launches, driven through tmux, reached the login screen, started no session and fired no hook, so the payload key sets below are properties of `exec` and the interactive path is unmeasured. The recorder was registered through a **project-scope** `<cwd>/.factory/hooks.json`, so `~/.factory/hooks.json` was never written, and every correctly-wired arm passed `droid` as its harness argument. **The Notification half is arrangement-unreachable rather than negative**, and the file records why by name: every agent turn needs a Factory account, the CLI says so on the interactive path and on `exec` alike, and all three notification types this binary can produce are fired from turn code. Because no turn ran in either mode, the account gate is a sufficient cause rather than a measured sole one — whether `exec` would suppress notification delivery on its own is unmeasured, and the file lists it as such. The control that makes that safe to write down is in the file rather than asserted beside it — the same recorder, registered additively on Claude Code 2.1.241 through `--settings`, recorded a real `Notification` carrying `idle_prompt` 64 s after `Stop`, in the same rig on the same machine on the same day. So the missing Droid record is the account gate and not the instrument. **What did run is the structural trap, measured rather than reasoned about.** A distinctly-named plugin replicating `cargento/`'s exact layout — a `.claude-plugin` manifest directory and a `hooks/hooks.json` nested under a top-level `hooks` key whose commands interpolate `${CLAUDE_PLUGIN_ROOT}` and pass the literal argument `claude` — was installed at project scope from a local marketplace, and Droid loaded it, expanded the variable, and ran the hook with `claude` as its harness argument, twice, before the auth gate. The recorder it reached lives inside the plugin root and makes no network call of any kind, so the demonstration could not itself post to `/api/events/claude`; what would happen if it did is closed in code in the same file, read-only against a synthetic id — `/api/events/droid` is refused as an unknown harness while `/api/events/claude` is **accepted** and truncates a 36-character id to eight, which matches no row the collector keys on the whole 36. `validate_hook_vocabulary` was green for this tree throughout, which is the point: it is keyed by file path and cannot say that a file is also read by Droid. **Positive on identity, on both candidate keys.** `session_id` is a 36-character UUID, the transcript filename stem equals it, and line 1 is a `session_start` whose `id` equals it too, so `sid = meta["session_id"] or basename(fp)[:-6]` takes the first branch and the fallback would agree — recorded both ways because the fallback is the case the question names. Two things are read in the binary and never run, and are labelled that way: the `Notification` payload's key list, and the vocabulary, where this version carries `permission_prompt`, `idle_prompt` and `elicitation_dialog` and **`auth_success` does not appear in the binary at all**, so the four-value list the vendor documents is three here. A finding outside the question rides along because it was found by running: `config.py` points `droid.projects` at `<home>/.factory/projects` and 0.202.0 writes `<home>/.factory/sessions/<slugified-cwd>/<session-id>.jsonl`, so with three real sessions on disk `discover()` returned False and `collect()` returned no rows. The per-record parsing was right; only the root was wrong, and DRC-4331 (2026-09-02) added `sessions` ahead of `projects`, which stays as the fallback. |

| `droid/billing-limits-0.210.0-macos.jsonl` | What Droid's `/limits` command calls, with which headers, and what comes back; and where the CLI keeps the credential it sends. droid 0.210.0, macOS, the user's own signed-in organization account, two interactive TUI runs driven through `tmux` from a scratch directory, with `FACTORY_API_BASE_URL` pointed at a loopback reverse proxy that forwarded to `api.factory.ai` and logged method, path, request header names, status and the response with every value replaced by its type. No hook exists in this path, so a purpose-built recorder wrote it, as `claude/usage-endpoint-macos.jsonl` did. Two values are kept on the limits route alone, booleans and one enum string, on the reasoning above: they are the vendor's vocabulary and they are why the answer is empty. **The route is pinned and the answer on this account is no windows.** `/limits` is `GET /api/billing/limits` with a bearer authorization and the CLI's `X-Factory-Client`, `X-Client-Version` and `X-Factory-Org-Id` headers, answered 200 with four top-level keys (`usesTokenRateLimitsBilling`, `overagePreference`, `canManageOverage`, `extraUsageAllowed`) and no `limits` object, and the CLI's own panel then read `Unable to fetch credit limits`, because it requires `limits.standard` and this account has `usesTokenRateLimitsBilling` false. The windowed shape the CLI is written against, `limits.standard.{fiveHour,weekly,monthly}.usedPercent`, is read in the binary and never run, and the file labels it so. **The login credential is out of reach by design**: the Keychain item `Factory CLI` holds a 44-byte encryption key under the account `auth-encryption-key-security-cli`, and the token sits encrypted in `~/.factory/auth.v2.loginkeychain` beside a refresh lock, so it is another program's encrypted, self-rotating store; the file names the sibling store names and the `FACTORY_DISABLE_KEYRING` disk fallback from the binary. `FACTORY_API_KEY` is the CLI's other credential route, sent on the same header, and is the route DEC-10 (DRC-4333) chose; whether the limits route accepts a key is unmeasured here. The probe also confirms, by running, that the collector root DRC-4331 added is the one 0.210.0 still writes to. |
Not every capture is a payload record. `codex/permission-hook-0.146.0-macos.jsonl` answers a question no payload could, because the payload never arrives: it pairs what each run asked for with the policy it was actually given, which is what turns "the hook did not fire" into "the hook cannot fire, and here is the mechanism".

`claude/permission-decision-2.1.238-macos.jsonl` is the same kind of file for the opposite problem: the hook fires every time, so what needed evidencing was that its *return value* is discarded. A run that returns the wrong spelling and a run against a harness that reads no spelling at all look identical, which is why the invalid-value control is in there. Its timing half also corrects a measurement error worth recording: a first attempt timed the gate from the hook's exit log, which is written after the hook sleeps, and so reported a 91s wait that was the instrument rather than the harness.
Expand Down
Loading