SOURCE / docs/implementation-v0.5.2.mdAT / 2dbeaf1View on GitHub ↗History ↗

v0.5.2 implementation notes

Baseline v0.5.1 (eeab1ba). Nothing here publishes, tags, pushes or installs.

Confirmed host storage capabilities#

The hooks runtime has no Node and no require. $.fs offers read, whole-file write, list, exists and stat only: no append, no mode, no rename. $.store is one JSON file capped at 4 MiB. $.process.run takes an argv; a refusal is final and is never retried by another route.

Persistence design (telemetry)#

Each process writes only its own sealed segment (hooks/telemetry.ts, telemetry-store.ts): 64 KiB per segment, 4 MiB total, 13 months, whitelisted fields only. A torn write is bounded to one segment. Retention deletion uses rm -f on exact telemetry file names and is best effort. Directory/file modes are requested with mkdir -m, read back with stat, and reported as verified or unverified. Storage is not append-only and no stronger claim is made.

Subagent prompt-cache policy#

Precedence (Claude Code 2.1.296, non-main requests): FORCE_5M, environment, setting, agent cacheTtl, ENABLE_1H, default 5 minutes; the controls need Claude Code 2.1.242 or later. The setting value is the string "1h". Environment changes need a new process; whether a running session reloads settings is unverified. /cockpit doctor reports configuration intent and an opt-in recommendation; it edits no settings, sets nothing, makes no model call and makes no savings claim. The 5-minute/1-hour cache-write split is not exposed by typed usage, so the effective behaviour is UNKNOWN.

Stop-hook verification guard (hooks/stopguard.ts)#

classic.Stop with { block: reason }. OBSERVE does nothing. ENFORCE runs the owner's verifyScript (absolute path, argv only, cwd the project) for a task that changed files, or, for a non-read-only task, a repository that shows uncommitted changes (so edits by MCP tools or Bash commands with no edit diff are not missed).

Trust model (remediation of the v0.5.2 review). A configured path is never trusted by itself. A person enrolls a script with /cockpit guard trust, which prints the path, the sha-256 of its raw bytes and the project, and enrolls nothing; only /cockpit guard trust <first 12 hex>, typed by a person (composer or bridge origin; since 0.5.3 not SDK, see implementation-v0.5.3.md), stores the record. Plugins, triggers, peers, models and unknown origins are refused. The record lives in the plugin store, per project and path, survives restarts and task resets, and ends when the bytes change; /cockpit guard untrust removes it. verifyScriptSha256 is checked on top of enrollment and never replaces it, which also means a repository that sets plugin options cannot get its own script run without a person enrolling it. A task keeps the verifier it started with. Not covered: anything the script itself runs (interpreters, test files, other scripts) and the interval between the hash and the exec (no exec-by-descriptor exists in the plugin API).

Refusal budget. Every check since the last one that completed the task is an attempt (attemptsOf); a pass that still left completion refused (no change summary) is an attempt and cannot reset the budget. Default 3, ceiling 10. When it is spent the stop is allowed and the task carries a protected hold (task.stopHold); DONE is never produced. The record is kept in the Run Ledger (stopChecks), so a resumed session finds the budget as it was. A closing message is required to carry a summary only when the host supplies one at all.

Freshness. The working-tree fingerprint is taken before and after the run; a tree seen to move is unstable and counts as an attempt, not as verification. Two fingerprints are not an atomic snapshot (an edit that is undone, or one outside git's view, is invisible), and a directory that is not a git repository has no fingerprint: the exit code is then accepted without stamping the gates, and the evidence says "tree state unknown". A pass sets only the TEST gate and never clears an Opus review or release approval. While the guard holds a task, the progress tool cannot unblock, block, or change the TEST gate (isGuardHeld in model.ts); only a person's /cockpit reset releases it. Other paths that set gates are the existing observed Bash readings, which do not remove the blocker.

Other outcomes: missing, untrusted or unstartable scripts are final for the task (BLOCKED, stop allowed, no retry); a timeout counts as a failure. A task is identified by number@start-time, so a reset never inherits an earlier task's attempts or alert history.

The hold is protected task state (task.stopHold)#

The hold is a field of its own, with its reason and refusal count; it is not the blocker text. No progress action (plan, replan, start, unblock, block, fail, complete, gate/gates, discover, decide, align), no Bash-ingested test result and no model-written STOP GUARD: blocker can create, move or lift it. A re-plan carries it (and the task's identity) forward; a read-only declaration cannot shed the TEST gate while it stands; the TEST gate cannot be set through the progress tool while it stands; and the model refuses 100% / DONE (isVerified, statusOf, the VERIFY milestone) on the field itself, whatever the host layer does. Only two things lift it: a fresh check by the enrolled script that passes and completes the task, or a person's /cockpit guard release (composer, bridge or SDK origin only), which clears the hold and starts the budget over but sets no gate: TEST stays pending, and SECURITY and release approvals are untouched. /cockpit reset is the explicit new task and carries nothing. The script is re-hashed before every execution and once after it; a swap between two runs or during one is untrusted, never a pass. An exception inside the guard holds the task (the Stop guard raised an error while checking); it is never a silent pass. A task declared read-only is measured against the tree, so a claim of read-only work cannot skip verification when changes are detected or the tree state is unknown.

Migration of tasks saved by the previous schema#

A task saved before the hold was a field (no guardSchema) is settled once, deterministically and idempotently (migrateStopHold), before any action can touch it. The evidence is only the guard's own check records in the Run Ledger, which the model cannot write; the blocker text proves nothing. An unfinished check record plus the guard's blocker, a spent budget, or a check that could not run: held, with the budget as the records show it (never reset). A guard-looking blocker whose records all completed, or whose records were unreadable: held as ambiguous, to be cleared by a fresh check or a person's release; nothing is granted. A guard-looking blocker with no record at all is an ordinary blocker and stays one. OBSERVE tasks, completed tasks, ordinary blockers and failed gates are unchanged. Script trust is not read from legacy hashes or configuration: enrollment lives in its own store, so a missing or changed script stays untrusted and the task stays held. Records of another verifier do not let a different script take the task over.

The task baseline and the read-only claim#

A model's "read-only" is a claim, not proof. When the Stop guard is enforcing, the host takes a working-tree fingerprint (startTree: HEAD plus the content hash of every tracked change and every untracked, non-ignored file) when a task begins, at the prompt that creates it and before any work of that prompt. An unfinished task keeps its baseline through later prompts, plans and re-plans; nothing a model sends can set or move it (it is not an input of the progress tool, and a later fingerprint never replaces it). A read-only plan is accepted only when (a) the baseline is a known fingerprint, (b) the fingerprint now is the same, and (c) no Edit, Write or Bash edit diff was seen for the task. Otherwise the task is planned as coding, the reply says so, and the check applies. At the Stop, a read-only task is measured against the baseline again, so a change after the declaration is caught too.

Consequences, stated plainly. No baseline (the first thing that happened was a progress call, or a task saved by an earlier build), no git repository, a tree too large to fingerprint, or git not answering: the state is unknown, and unknown is treated as changed, so a read-only claim does not skip verification. The cost is that read-only work in such a place is checked like coding work. Coverage limits: git-ignored files, files outside the repository and anything git does not list are not in the fingerprint; changes there are not detected. Nothing in this paragraph applies when the guard is not enforcing; the read-only claim is then accepted as before.

Prompts, resets and sessions#

A prompt is not a reset. A task the Stop guard holds goes on through a person's next prompt, planned or not, with its hold and budget; only /cockpit reset (person-origin) abandons it. The reset marks nothing as passed or done, and the abandoned hold stays in the check record as a released entry named reset with its reason. The check records are kept in the Run Ledger and also in a durable, versioned store key (stop-checks, version 1; a record of an unknown version is ignored, and each record is validated by shape), because a task can be carried into a session whose Ledger starts empty. The attempts spent, the verifier a task began with and the history of a hold are read from both, so a new session neither resets a budget nor lets another script take a task over. If a record is lost from both, nothing is reconstructed or assumed: a guard-looking blocker with no record stays an ordinary blocker, the stop is checked again, and nothing is claimed as verified.

A migrated hold is reconciled with the rest of the task at once: a task saved as DONE / 100% with an unresolved guard hold reads BLOCKED below 100% after migration, and the next prompt keeps the hold. A legitimately completed legacy task is left as it was.

What the plugin controls, and what it does not#

The controls here are plugin-level: the hold, the baseline, the enrolled hashes and the check records are fields and store keys the plugin API gives the model no way to write, and the progress tool has no input that reaches them. They are not a sandbox. A model that is allowed a shell, or a tool that reaches the host's files, may be able to write the plugin store file or the project files directly; nothing here stops that, and this document does not claim the store cannot be modified through every capability a host offers. What the design does give is that the ordinary routes (the progress tool, plans, prompts, test output, forged blocker text) cannot lift a hold or skip a check, and that enrollment, release and reset need a person's own command.

Completion integrity (H1-b)#

One rule decides DONE: isVerified in hooks/model.ts (no protected hold, every required gate pass or not applicable, the review and the goal check satisfied). Status, percent, phase, the HUD, the Ledger projections and the Stop guard all read it; nothing else may say DONE.

  • Hold on every check that did not complete the task. A failed, timed-out, unstable, errored, untrusted or pass-without-summary check holds the task at once (stopHold, refusals counted). The refusal budget bounds the loop of continuations, not the hold: when it is spent the stop is allowed and the task stays BLOCKED. A later passing check with a change summary lifts the hold.
  • Read-only is only as good as its evidence. A change seen after a read-only plan, whether an edit the hooks observed (touchFile) or a tree that differs from the task's start (reclassifyChanged at the Stop), makes it a coding task: edited, every gate required and unset, no not-applicable exemption kept, goal check invalid. Declaring read-only again is refused on an edited task.
  • Release makes nothing optional. /cockpit guard release clears the hold, sets TEST required and pending, restores neither DONE nor 100%, and waives no security, Opus or release requirement.
  • /clear and a prompt. /clear writes a released record for a held task (abandoned, not passed, not done). A prompt settles a legacy held task before it can start another one.

Change detection: UNCHANGED, CHANGED, UNKNOWN#

compareTree(start, now) yields UNCHANGED, CHANGED or UNKNOWN. UNKNOWN is never read as UNCHANGED (the check runs under ENFORCE) and not read as CHANGED either (a read-only task is not converted, and a pass is real evidence). The reason is written in the check record the Ledger keeps: [change detection unknown: <reason>].

Reasons for UNKNOWN: not a git work tree or git unavailable; no commit to measure against; a git command failed, timed out or its output was cut short; the index listing failed or exceeded the host's output limit; more than 20,000 changed, untracked or flagged files; a file could not be hashed (deleted meanwhile, unreadable, absent in a sparse checkout); no starting fingerprint (the task began without a prompt).

Coverage added: files marked assume-unchanged or skip-worktree (git ls-files -v tags) are hashed from the disk; .git/info/exclude and core.excludesFile are part of the stamp, so hiding a new file there is itself a change. Not covered: the default global ignore file when core.excludesFile is unset, files git ignores through .gitignore (unchanged), and any writer that leaves no trace in these. A model with a shell can still alter Git metadata; this is a change detector, not a boundary.

Measured, with git 2.x on this machine, replicating the exact git calls of the fingerprint (a scripted replay of the calls, one run each, wall clock):

repositorytracked fileschanged + untrackedgit subprocessestime
cobalt-cockpit12251124 ms
a local project53601041 ms
ComfyUI1,23901043 ms
synthetic, clean60,000010664 ms
synthetic, 5,000 untracked60,0005,00060 (100 per call before, now 400)578 ms
synthetic, 1 tracked edit + 5,000 untracked60,0005,00161590 ms

A clean tree costs a constant ten git calls whatever its size; only changed or untracked files cost hash calls (one per 400 paths). The earlier cap of 2,000 made a repository with a few thousand untracked, non-ignored files UNKNOWN for no good reason; it is now 20,000. The host's own limit on process output is not known from here: a repository whose ls-files -v listing is cut short is reported UNKNOWN with that reason. The live acceptance measures it on a real large fixture. Figures are one run on one machine, not a benchmark.

Mutation audit of the guard's tests#

Each protection was removed on its own in a disposable worktree and the suite run (about 45 mutations over three rounds). Every one that violates an invariant fails a named test: the hold in isVerified, statusOf and the VERIFY milestone; the TEST-gate refusal; the read-only proof (fingerprint, edit and unknown-baseline legs separately); the downgrade to coding; the Stop-time measurement against the baseline (unknown baseline, unknown fingerprint); the baseline carried across prompts; the prompt/hold rule; the migration and its reconciliation of a saved DONE; the durable records and their version check; the verifier lock and the released exception; the final-branch hold restoration; the reset record and its person-only origin; the re-hash before and after each run; and the exception-to-hold catch. Surviving mutants are equivalent or defense in depth, not gaps: the planTask branch that keeps TEST required on a held task is behind the forced coding kind (one layer cannot be removed without the other being redundant), and a mutant that gave the first progress call a "known" baseline was also stopped by the string check beside it. None of them lets a task read DONE without the check.

Guard-owned completion (H-1, opus-5-955bfb)#

In ENFORCE (policy ENFORCE, a usable verifyScript) a task is DONE only on a record the Stop guard wrote: task.guardDone = {uid, tree, check, outcome, at}. isVerified requires it, so status, percent, phase, the HUD, the Ledger projection and /cockpit status all say UNVERIFIED until the guard has spoken. No progress input reaches it; milestones, gate reports, criteria, a goal check, a TEST report, a new read-only plan and a person's release do not create it.

  • Who writes it. stopGuard, in the same step that lifts the hold, after the check result was persisted to the Ledger and the durable key (outcome: verified, the tree the check ran on); and recordUnchanged, for a read-only task whose tree was measured equal to its own start fingerprint (outcome: unchanged, Ledger record unchanged, no script run).
  • Provisional hold. Before a check starts the task is held ("a Stop check is in progress"). A host that kills the hook, an error, an Esc or a crash therefore leaves a held task. The hold is replaced by the decision: a lasting hold with the reason, or the record.
  • Unknown tree is not a match. A pass whose tree the guard cannot identify (git unavailable, timed out, over a cap) is recorded with the reason and is not a completion: no record, the task stays held, the stop is allowed. Consequence: ENFORCE needs a git work tree to ever reach DONE.
  • Reconciliation (reconcileCompletion, at every prompt, SessionStart and a turn that ended any way but an answer): a task whose milestones are all done is checked against the record. Missing, for another task, or for a tree that no longer matches (or cannot be identified): the task is held, the record withdrawn, and the next prompt continues that task instead of taking the unchecked tree as its baseline. A read-only task whose tree still equals its start is recorded and finishes. Repeated Stop callbacks on a matching record run nothing.
  • Edits. Any observed edit (main loop, helper, or a moved tree) goes through markEdited: read-only becomes coding with every gate required, edited is set for good, the record is withdrawn.
  • Release. A person's release clears the hold, drops any record, sets TEST required and pending and reopens the finished VERIFY milestones, so the task is not done and is not re-held in a loop; the guard must complete it.
  • Old tasks. A task finished before this existed has no record and is held once at the next prompt; one passing check settles it. A pass recorded without a tree identity does not count.
  • Owner decision. The earlier test that accepted unverified work between tasks as the next task's baseline was changed: in ENFORCE that is no longer allowed.

Git fingerprint hardening (L-1). Every git call of the fingerprint now runs with --no-replace-objects, -c core.fsmonitor=false, -c core.untrackedCache=false, -c diff.ignoreSubmodules=none and --ignore-submodules=none on diff. Reproduced with real git before the change: a replace ref made a modified file look unchanged; submodule.<name>.ignore=all hid a modified submodule; a nested .gitignore containing * hid a new file from the untracked list (and so did any exclude source, including the default global ignore file). A crude core.fsmonitor hook did not hide a change in the test, and the option is still disabled. The names of ignored entries (ls-files -o -i --exclude-standard --directory) are now part of the stamp, so a file moved from untracked to ignored changes it. Cost: an ignored directory is one entry, but a read-only task that creates an ignored entry (for example a new __pycache__) now reads as changed and is checked; capped at 20,000 entries. Not covered: writes inside an already-ignored directory, a writer that leaves no trace git can see, and git metadata a shell rewrites between the start and the end.

Ignored files: what the fingerprint contains, and what it does not#

Included (one stamp, taken by the host when a task begins and again at each Stop/prompt, never an input of the progress tool): the commit it is measured against; the content hash of every tracked change and every untracked, non-ignored file; the content hash of files flagged assume-unchanged or skip-worktree; the deletions; the content hashes of .git/info/exclude, core.excludesFile, .git/info/attributes, core.attributesFile, the verifier script and every operator-listed stopWatch path (whatever git says about them); the NAMES of ignored entries (ls-files -o -i --directory, an ignored directory is one entry) with the inode, size, and modification and change times (nanoseconds) stat reports for each; every tracked file git lists as stat-dirty that diff does not report is also hashed without filters and compared with its blob at the base commit; all of it with git's replace refs, fsmonitor, untracked cache and submodule-ignore settings switched off.

Not included: the content of files inside an ignored directory (a directory's own modification time only moves when a direct child is added or removed), anything an unobserved writer changes without a trace git or stat can see, and the default global ignore file's content (its effect is caught through the ignored-entry list).

Classification, per comparison against the task's own start: UNCHANGED (known and equal), CHANGED (known and different, or an observed edit), UNKNOWN (no baseline, or the tree could not be measured: the reason is recorded). A read-only claim is never an input to any of them. UNKNOWN runs the check and records no completion.

False positives are real and listed rather than hidden. Ordinary ignored output moves the stamp: measured on this machine, ComfyUI lists 117 ignored entries (__pycache__/ in several directories, temp/, user/, input/…), cobalt-cockpit 3 (.claude/, .claude-plugin/types/, .freebuff/). A read-only task that runs a command creating or touching one of those reads as changed and is checked (it is then a coding task; that costs a check, not correctness). The operator removes known noise with the stopVolatile option (comma-separated repository-relative directories ending in /): ignored entries under them leave the stamp. This is an authorization the model cannot give: it is a plugin option, never a tool input; it never excludes a tracked or untracked file; a prefix that covers the verifier or a watched path is refused; and everything a model writes inside a volatile directory goes unseen. The default is none.

Cost, measured (one run each, wall clock, warm cache): an ignored-entry listing plus stat of those entries took 10 ms plus 5 ms for 117 entries (ComfyUI), 5 ms plus 4 ms for 3 entries (cobalt-cockpit), and 79 ms plus 5 ms on a 60,000-file repository with a 30,000-file ignored dist/ (one entry). stat is run once per 400 entries, so 20,000 entries cost at most 50 calls; beyond 20,000 ignored entries the tree is UNKNOWN. GNU stat --printf is tried first, BSD stat -f second; if neither works the tree is UNKNOWN with that reason.

Mutation audit of the completion-integrity round#

Sixteen mutations, each in a disposable worktree, one protection removed at a time; every one fails at least one named test: hold only at budget exhaustion (6 tests), no conversion of a read-only task on an observed edit (2) or at the Stop (6), release leaving TEST optional (1, the pure-model test: through the host the conversion to coding masks it, so this one is defense in depth and has its own test so the release rule cannot silently depend on the conversion), read-only re-declared after an edit (1), index flags ignored (2), exclude sources ignored (4), no legacy adoption at a prompt (1), no record on /clear (1), read-only exemptions kept after conversion (5), a held task allowed to be DONE (6), cap back at 2,000 (2), UNKNOWN read as UNCHANGED (4), UNKNOWN read as CHANGED (1), reason not recorded (2), a held task skipping its check on an unchanged tree (1). One first attempt (index flags) survived because the test world's git diff listed flagged files, which real git does not; that was a defect of the test double, fixed, and the mutation then failed 2 tests.

Review pass A (opus-6-49d904, GO, no High) added three real findings that are fixed here. (1) Reproduced with real git 2.56: filter.<n>.clean plus a line in .git/info/attributes makes git diff HEAD report a modified tracked file as unchanged (--attr-source does not help; ls-files -m still sees it). The stamp now hashes stat-dirty tracked files without filters and compares them with their blob at the base commit (a file that was only touched does not count), hashes the attributes sources, and pins core.checkStat=default and core.trustctime=true (the checkStat variant did not reproduce). (2) A write after the guard recorded a completion (Bash, Write, Edit, MultiEdit, NotebookEdit, a non-Cockpit MCP tool) withdraws the record at once; reads do not. (3) A pass whose first fingerprint was unknown is not a completion; ENFORCE with a script named is enforcing from registration (before the home directory is known). Cost: the fingerprint is now 15 to 16 git calls plus one stat call per 400 ignored entries. Still not covered, by design and stated: refs (a read-only task that commits on another branch and switches back), writes inside an ignored directory, core.worktree pointed at a copy (unverified).

Mutation audit of the guard-owned completion round#

Twenty-six mutations in a disposable worktree, one protection removed at a time. Killed by named tests: no provisional hold; DONE not requiring the guard record; the record not matching the task; no reconciliation at a prompt, at SessionStart or on an interrupted turn; an unidentified pass counted as complete; the tree not compared at reconciliation; an observed edit keeping the record; a release leaving the finished work done (after a test was added for the re-hold loop it causes); a helper edit on the old path; git without replace-object protection or without --ignore-submodules=none; ignored entries not measured; watched paths and the verifier not hashed; the volatile filter ignored; a volatile prefix allowed to cover the verifier; a volatile prefix allowed to hide an untracked file (first attempt survived because one test changed a tracked file at the same time and masked it; split into two tests, then killed); a repeated Stop re-running the script. Two did not fail a test, for stated reasons: recordUnchanged's own equality check is redundant with both callers (they establish equality first), so it is defense in depth, not a gap; and listing ignored entries by name was redundant with stat, which prints the name, so the separate name lines were removed.

Trust boundaries and the time-of-check/time-of-use limit#

  • verifyScript is read from the plugin's own options; no model output, prompt or tool result can set it. Whether a project-scoped settings file can set plugin options is a host property not verified here; enrollment makes it safe either way.
  • The check runs with the plugin API's $.process.run, a native hook-runtime call. It is not passed through the Auto-mode classifier as a Bash tool call, and this document does not claim it is. Observed live: the runs executed without a classifier prompt. The environment is the plugin host's own (the API can add variables, not remove them).
  • Every failure to complete a check leaves the task not DONE.
  • The person can always interrupt or end the session; nothing here blocks cancellation.

Live acceptance (Claude Code 2.1.296, --permission-mode auto, disposable fixtures under /tmp/accept)#

A OBSERVE: failing test appears in the Ledger as a failed Bash call; no block, no alert, nothing verified. B ENFORCE: first check exit 1 (stop refused), repair, second check exit 0; TEST pass, the other gates unset. C always-failing script: two attempts then BLOCKED, TEST fail, never DONE. D pin mismatch: script never executed (no marker file), BLOCKED, no loop; missing script: BLOCKED, no loop. E alert: one security-failure alert across repeated status calls; headless -p has no terminal surface, so the host reported no-surface and the Ledger records failed (no-surface); SECURITY stays fail. A real SENT cannot be produced headlessly (-p has no terminal surface), and at this point an interactive run had been declined because it needed a trust-folder write to the user's configuration, so SENT was then covered by host-level tests against a mocked channel only. It was produced interactively afterwards; see Interactive SENT acceptance below. F review holds, NO-GO, restart: deterministic host-level tests (tests/alerts-holds.test.ts); no live Opus call was made for them.

Live re-check of the two review findings (Claude Code 2.1.296, --permission-mode auto, snapshot of the remediated commit, /tmp/accept2)#

  • Loop: ENFORCE with a script that exits 0, budget 2, a model told to end with the single word "Done." Result: two checks recorded, neither complete, then the stop was allowed with the task BLOCKED; never DONE. (A first run showed a check that leaves an untracked cache directory moving the tree; it is now run once more from that tree.)
  • Trust across sessions: a person enrolled the script with /cockpit guard trust <12 hex> (accepted live; hash equals sha256sum). The agent then rewrote the script. Session 1 and a separate session 2 each recorded untrusted: changed since it was enrolled, never a pass, and the task ended BLOCKED. (The marker file the edited script touches exists because the model itself ran it, not the guard; the Ledger holds no pass record.)
  • The interactive terminal notification test was still pending at this point. It was completed afterwards (next section).

Interactive SENT acceptance (Claude Code 2.1.296, 2026-10-11)#

Run by the owner in a terminal, in a disposable working directory that was not a git repository, with --permission-mode auto, a separate Claude Code configuration directory, and --plugin-dir pointing at a snapshot whose tracked files are byte-identical to f42fa58c2cdc9ae7da9ba58fe575b45001ac032d (the only other files in the snapshot were the host-written declarations under .claude-plugin/types/, which are not shipped). Options: guardAlerts: true with a 30 s cooldown (the /cockpit guard line read ARMED · 0 of 5 alerts used this session · at most one every 30s), profile SONNET_LED with orchestration, Sonnet 5.5 as the main model at medium effort. The prompt asked the model to plan one milestone with the progress tool, start it and block it, and nothing else; the blocked task raised one run-blocked alert for task 1.

  • Host result: $.ui.notify answered isSent: true with channel terminal_bell. The Run Ledger of each session records the alert as run-blocked · task 1 · sent via terminal_bell, attempts 1. Two sessions produced the same record, at 13:00:01 and 13:26:13 UTC; /cockpit guard showed ARMED before the alert in both.
  • Owner observation, separate from the host's report: the terminal's bell, flash and popup were seen at the moment of the alert. The plugin itself still cannot confirm that a notification was seen; SENT is only the host's report that it wrote the notification. The observation is the owner's and is recorded here as the acceptance of the interactive gate.
  • Earlier attempts the same afternoon are recorded as what they were: headless -p runs ended failed (no-surface), and one interactive session whose terminal offered no notification channel ended failed (no-channel). Neither counts as SENT.
  • The same sessions wrote their telemetry segments under ~/.local/state/cobalt-cockpit/telemetry/, and on inspection afterwards the directory carries mode 0700 and the segments 0600, so the telemetry maintenance calls also ran under Auto mode without a classifier prompt.

The documentation-only commit after f42fa58 changes no hook, script, test, type, manifest or configuration, so this acceptance is carried as evidence for it rather than repeated; a commit that changes hooks/alerts.ts or the notify call in hooks/register.tsx needs it again.

Cobalt Guard (hooks/alerts.ts)#

Policy over observed state, evaluated after each progress/swarm tool call and at Stop. Delivery is $.ui.notify, the person's own terminal channel. Its result says the host wrote it, not that anyone saw it, so states are SENT/FAILED, never "delivered". Recoverable failures and verified success send nothing. Dedupe key is task-identity:kind:detail (the identity survives resets), also checked against the Ledger so a resumed session does not repeat; only a notification the host wrote ends the matter, a failed or refused one is retried up to three times after the cooldown, and an alert held back by the cooldown or the budget is recorded once with that reason; cooldown (default 300 s) and a per-session budget (default 5) bound the rate; a re-entrancy flag prevents loops. Messages come from a closed vocabulary plus a task number. Alerts are recorded in Ledger.alerts (optional field, bounded to 32; older ledgers read unchanged). /cockpit guard ack|silence (person origin only) change the display and future sends, never a verdict. Remote paging (webhook, email, push) is deferred to v0.5.3.

Limitations#

  • No true append and no mode API; permissions are known only by stat read-back.
  • The plugin load order means duplicate-Cockpit detection is partial.
  • In the live acceptance runs (--permission-mode auto, Claude Code 2.1.296) Auto mode did not restrict the plugin's own file and process calls: the verification script and the telemetry maintenance ran without a classifier prompt. A host version that changes this is not verified; a refusal is recorded as unverified and not worked around.
  • A notification cannot be confirmed as seen by the plugin; SENT is the host's report that it wrote it. The owner's own observation during the interactive acceptance is recorded above.
  • The Stop guard cannot judge a script's quality; the owner owns the check.

Mutation triage (round 3): the stat-dirty comparison against the base blob is covered by the touched-file negative control; the uid check on the final record write (H8) is defence in depth, because reset and replacement already withdraw the record elsewhere and no host-level sequence reaches it. It is documented as an equivalent mutation, not a tested one.

Remediation of the second security review (base 364699b, NO-GO: B1, B2, B3)#

This section supersedes the earlier statements about ignored directories (--directory), about git status as a fallback, and about stopWatch covering directories.

B1, a task with no starting fingerprint. A task is measured only against its own start. If it has none (git was unreadable when it began, there was no commit yet, or no prompt began it) the state is UNKNOWN even when git is readable and clean at the Stop; the end state is never used to rebuild a baseline. git status is no longer consulted for a task's change state. UNKNOWN runs the check, records the reason, and a pass on a tree identified only afterwards completes nothing it was not asked to: the refusal budget still bounds the loop.

B2, work with no task. Obligation is kept apart from the task presentation (stop-owed, session state the progress tool cannot reach). It is set when a tool that can write (Bash, Write, Edit, MultiEdit, NotebookEdit, a non-Cockpit MCP tool) finishes while no task exists, or when /cockpit reset or /clear abandons a task that edited, touched files or was held and was not completed by the guard. It is cleared only by a completed check on an identified tree. While it stands, a Stop with no task creates an edited task and checks, and the next task a person starts is a coding task from its first moment. No reading of a Bash command is trusted to say that it did not write, so any Bash call counts. Reads (Read, Grep, Glob) never create it. What stays outside: a conversation that never ran a write-capable tool and shows no change in a readable repository is not made to verify, including where git cannot be read (the host gives no further evidence); the obligation is session state, so a process restart does not carry it; a task that exists is measured by its own fingerprint instead.

B3, ignored files. (Review pass A of 0f60f63 found two gaps in the first version of this, both reproduced with real git and find and fixed: git lists an ignored directory that holds its own repository as one entry even without --directory, so such entries are expanded to their files with find -H under the same bound; and find exits 1 with partial output when a subdirectory is unreadable, so a watched or embedded directory that exists but cannot be listed in full is UNKNOWN, never "missing". Files inside registered submodules are not listed by ls-files -o at all and are not covered.) Every ignored file is listed and measured (inode, size, modification and change time in nanoseconds), not the top-level directory: a directory's own stat does not move when a child is rewritten, so a collapsed entry proved nothing about its contents. Rewrites (same size included), new files and nested directories are changes. Operator-authorized stopVolatile prefixes are pruned at the listing itself (:(exclude)<prefix> pathspecs), so a generated tree costs nothing. stopWatch accepts directories: git cannot hash a directory, so its files (found with find -H: a link given as the watched path is followed, links inside it are listed as links and not followed) are measured the same way. Bounds: 100,000 ignored and watched files and a 25 s budget for the measurement; past either the tree is UNKNOWN with the reason, never a partial match. Metadata (not content hashes) is the measure: change time cannot be set by an ordinary writer, so a rewrite cannot restore it; a writer with root, a restored backup that preserves it, or a file system with coarse timestamps is outside what this proves.

Measured (git 2.x, this machine, warm cache, 7 runs; listing plus stat in chunks of 400; the fingerprint's other git calls add about 25 ms): 200 ignored files 4 ms; 5,000 33 ms median (35 worst); 19,000 116 ms (122); 100,000 638 ms (649); 60,100 files with 60,000 of them in a volatile directory: 53 ms without the exclusion, 32 ms with it. The old --directory listing took 2 to 3 ms at every size. Of 12 repositories on this machine, 9 have more than 20,000 ignored files (24,558 to 92,242, and 240,543 in a version-manager checkout); only that last one exceeds the 100,000 bound. A repository with an ignored tree beyond the bound is UNKNOWN on every Stop until the operator lists it in stopVolatile, which means a check at every stop and no completion record. That is the price of not hiding rewrites inside ignored directories by default; it is stated, not softened. Writes the operator excludes with stopVolatile go unseen, as before.

Not covered, unchanged: files outside the git top level, a writer that leaves no trace in the fingerprint, git metadata a shell rewrites, processes that change files after the last measurement.

Named, not hidden: git's default global attributes file ($XDG_CONFIG_HOME/git/attributes, used when core.attributesFile is unset) and a filter.<n>.clean in .git/config are not part of the fingerprint; with a filter attribute in place a same-size edit can hide from git diff and ls-files -m. This is the disclosed "git metadata a shell rewrites" class. Under ENFORCE a directory that is not a git work tree runs a check at every Stop and a pass there never completes (UNKNOWN, fail closed); that is a usability cost, not a hole. Residuals judged low and left open: /cockpit reset of a task whose writes were all unobserved Bash writes carries no obligation (it needs a person's action); a tool call that throws before its end hook records no obligation.

Remediation after pass A of 73c8e79 (LOW-1, LOW-2, documented residuals)#

LOW-1, a listing that exits 0 but did not list everything. Reproduced with real git: in a repository with a directory of mode 0333 holding a new file, git ls-files -o -z exits 0, prints nothing for that file, and says only warning: could not open directory 'd/': Permission denied on stderr. The untracked and ignored listings now treat any stderr output as an incomplete listing: the tree is UNKNOWN with the git warning as the reason, never UNCHANGED. Regression tests (LOW-1 in tests/baseline.test.ts) fail on 73c8e79 and pass now. The plugin test runner cannot start real git (a dynamic import of a Node module is refused), so the unit tests use a mock that emits that exact stderr; the real-git behavior was reproduced with a shell fixture and checked live in Auto mode.

LOW-2, a watched path under a parent that cannot be entered. Reproduced with real find and stat: both fail for p/q under a mode-000 p, and the old rule "find and stat both fail means missing" turned this into a constant "missing" that a writer could keep equal at both measurements. A path is now "missing" only when stat (C locale) says No such file or directory; any other failure, permission denied included, makes the tree UNKNOWN.

Named residuals (not covered, by design).

  • A repository nested inside the work tree that is not registered as a submodule, and a dirty or unreadable submodule, are reported by git as a single entry or not at all; the guard cannot see inside them. A task that depends on them stays UNKNOWN or relies on the person watching the path with stopWatch. This is a persistent UNKNOWN where git itself cannot tell, never a false UNCHANGED.
  • Anything git cannot list because of file permissions is UNKNOWN (LOW-1), so a work tree with an unreadable directory never reaches a verified DONE until the directory is readable or excluded.
SITE HUDSOURCE / 2dbeaf1 · mainVERSION / 0.6.5 (manifest on main)SYNC / via GitHub APIHow this page stays current

Suggested

  • PAGEInstall

    Two commands, then /cockpit

  • PAGECommands

    Every documented command

  • PAGEConfiguration

    Every option and its default

  • PAGEDiscovery, decisions and goal check

    What DONE requires

  • PAGESafety and trust

    What Cockpit observes, stores and never approves