@sabaiway/agent-workflow-kit 4.2.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +104 -0
- package/README.md +1 -1
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/gate-approve.mjs +58 -7
- package/references/modes/coverage-check.md +1 -0
- package/references/modes/velocity.md +3 -2
- package/references/shared/command-shapes.md +25 -24
- package/tools/coverage-check.mjs +113 -5
- package/tools/path-inventory.mjs +516 -0
- package/tools/repo-search.mjs +217 -29
- package/tools/run-gates.mjs +91 -6
- package/tools/velocity-profile.mjs +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,110 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
|
|
|
4
4
|
is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
|
|
5
5
|
every `migrations/<version>-<slug>.md` newer than it, in semver order.
|
|
6
6
|
|
|
7
|
+
## 4.4.0 — the guard stops charging you for answers it already has (AD-082)
|
|
8
|
+
|
|
9
|
+
**If the agent keeps asking you to approve its own `2>/dev/null`, this release is for you.** Two
|
|
10
|
+
things changed, and neither refuses anything.
|
|
11
|
+
|
|
12
|
+
**1. The ask now talks to the AGENT, not only to you.** Until now, when the guard stopped a read
|
|
13
|
+
carrying a redirection it explained itself in *your* approval dialog — and the agent that composed the
|
|
14
|
+
command never heard a word of it. So it composed the same shape again next turn. The ask now also
|
|
15
|
+
carries a hint back to the agent naming the lane that would not have prompted at all. Nothing is
|
|
16
|
+
blocked and nothing new is approved; if your host does not support the field, you get exactly the old
|
|
17
|
+
behaviour. **You should see the same class of prompt arrive less often, not disappear** — the honest
|
|
18
|
+
claim, stated because the alternative would be a promise this cannot keep.
|
|
19
|
+
|
|
20
|
+
**2. Two lanes so several small questions stop becoming one composed shell.**
|
|
21
|
+
|
|
22
|
+
- **`path-inventory.mjs` (new).** Does this exist, what type is it, how big, how many lines, what is
|
|
23
|
+
in that directory, what does this small config say — for any number of paths, in ONE call, promptless.
|
|
24
|
+
**A path that does not exist is a normal answer** (`exists:false`, exit 0), because "does either of
|
|
25
|
+
these exist" is a question whose interesting answer is "no", and a tool that errors on it sends you
|
|
26
|
+
straight back to writing a shell one-liner. Symlinks are reported by type and never followed;
|
|
27
|
+
binaries are reported, never decoded; every bound names itself instead of truncating quietly.
|
|
28
|
+
- **`repo-search.mjs --paths-file`.** The pattern half already had a file lane; the TARGET half did
|
|
29
|
+
not, so a path containing a backtick or `$(` could not be searched without a prompt however you
|
|
30
|
+
quoted it. Now it can. Both lane files are excluded from the search itself, by real path.
|
|
31
|
+
|
|
32
|
+
**What you must do to get the promptless part.** Re-run `/agent-workflow-kit velocity --kit-tools`.
|
|
33
|
+
Allow rules are values already written into your `.claude/settings.json`; upgrading the kit does not
|
|
34
|
+
edit them. The advisor now tells you exactly which entries you are missing.
|
|
35
|
+
|
|
36
|
+
**Also in this release:** the guard's recovery advice is derived per tool (it used to name
|
|
37
|
+
`--pattern-file` for every scanned tool, which was wrong the moment a second one existed), and it now
|
|
38
|
+
names both possibilities — pass the byte out of band if it is part of an argument, drop it if it is a
|
|
39
|
+
real redirect — because the guard cannot tell those apart and should not pretend to.
|
|
40
|
+
|
|
41
|
+
**One consequence worth knowing about.** The guard's scanned-tool match is a substring match across
|
|
42
|
+
the command, deliberately inclusive because an over-match only ever costs an extra prompt. Adding a
|
|
43
|
+
second tool extends that: a command that merely MENTIONS `path-inventory.mjs` and carries a
|
|
44
|
+
redirection now asks where it previously got no decision at all. That is the same licensed over-ask
|
|
45
|
+
`repo-search.mjs` has had since 4.2.0, on one more path string.
|
|
46
|
+
|
|
47
|
+
**One rule worth knowing if you pass paths to either tool.** A target must name exactly one thing:
|
|
48
|
+
no empty value, no NUL byte, no `..` component (Node collapses it before the filesystem is consulted,
|
|
49
|
+
so the tool could answer about a different file than you named). A trailing `/` or `/.` is not
|
|
50
|
+
rejected — it asserts "this is a directory", exactly as it does to your shell. Awkward names are
|
|
51
|
+
still supported: spaces at the edges, backticks, control bytes — that is what the file lanes carry.
|
|
52
|
+
|
|
53
|
+
**Not in this release, deliberately.** A guard rung that DENIES instead of asking was designed,
|
|
54
|
+
reviewed and withdrawn — for the second time — because a command substitution can embed an arbitrary
|
|
55
|
+
command with no separator byte, so any such refusal can destroy work it never meant to touch. The
|
|
56
|
+
guard still never denies.
|
|
57
|
+
|
|
58
|
+
## 4.3.0 — the coverage gate no longer certifies evidence it cannot bind to your tree (AD-081)
|
|
59
|
+
|
|
60
|
+
**Read this if you have ever run `coverage-check --check` on its own.** Until now it read whatever
|
|
61
|
+
LCOV happened to be at the fixed path and gave you a verdict. Nothing tied that file to the tree it
|
|
62
|
+
was judging.
|
|
63
|
+
|
|
64
|
+
**The harmless direction is the one you may have already seen:** you add tests, re-run the checker
|
|
65
|
+
alone, and it prints the *identical* failure list and the *identical* `lcov-sha256` — because the
|
|
66
|
+
LCOV is produced by your `unit-tests` gate and nothing regenerated it.
|
|
67
|
+
|
|
68
|
+
**The same mechanism prints a false PASS,** and that is why this shipped as a fix. Run the suite,
|
|
69
|
+
append one executable line, re-run the checker alone: it certifies "every changed Node line is
|
|
70
|
+
covered". LCOV carries no executability signal, so a line that did not exist when the suite ran has
|
|
71
|
+
no `DA` entry and reads as *non-executable* — nothing to cover.
|
|
72
|
+
|
|
73
|
+
**What changed: a coverage VERDICT is now an outcome of `run-gates --final`, and nowhere else.**
|
|
74
|
+
That run already owns the artifact end to end — it deletes the LCOV before any gate starts, so inside
|
|
75
|
+
it "this came from this tree" is a fact rather than a hope. It now mints a random nonce and records
|
|
76
|
+
the attempt as a one-way commitment over `{nonce, tree fingerprint, base}`; the checker recomputes
|
|
77
|
+
that commitment and refuses to certify without it. The commitment is also the only place your base
|
|
78
|
+
commit is bound, so an identical dirty diff at a moved `HEAD` no longer looks like the same tree.
|
|
79
|
+
|
|
80
|
+
**Three outcomes replace two — and your findings are unchanged:**
|
|
81
|
+
|
|
82
|
+
- inside `--final` → the verdict, exactly as before;
|
|
83
|
+
- anywhere else → `attested=no` and `NO VERDICT`, exit 0, with **every finding still printed**;
|
|
84
|
+
- a context describing a different tree, or matching no recorded attempt → `REFUSED`, exit 1.
|
|
85
|
+
|
|
86
|
+
**Uncovered lines still exit 1 and are still listed `file:line`.** Nothing that was red turns green;
|
|
87
|
+
only the *attestation* is withdrawn where it was never earned — which is why this is a minor release.
|
|
88
|
+
|
|
89
|
+
**Two more places where a green could hide:** the runner no longer trusts the checker's exit status,
|
|
90
|
+
because that code is 0 both when it certifies and when it withholds — it now reads one anchored
|
|
91
|
+
`attested=` line, so **a run that actually consumed an LCOV can no longer mint a green receipt
|
|
92
|
+
without certifying it**. (A run that produced no LCOV at all still records a green receipt with
|
|
93
|
+
`lcovSha256: null` and a loud `skipped-no-lcov`, exactly as before — that path is unchanged.) And a
|
|
94
|
+
plain run prints the withheld verdict aloud instead of leaving a PASS row standing over a claim
|
|
95
|
+
nobody made.
|
|
96
|
+
|
|
97
|
+
**Stated honestly — what this does NOT cover.** Whoever runs both processes can still forge the store
|
|
98
|
+
or the code; that is the same self-discipline posture the receipts have always had. And "the run owns
|
|
99
|
+
the artifact" is exclusive **by convention over a fixed path, not enforced**: if something else writes
|
|
100
|
+
that path while a `--final` is in flight — a second `run-gates`, a hand-run `--only unit-tests`, an
|
|
101
|
+
orphaned test process — the verdict can still land on evidence this run did not produce. Closing that
|
|
102
|
+
needs a per-attempt artifact path, which means your `unit-tests` gate cmd would have to reference
|
|
103
|
+
`$AW_LCOV_FILE`; it is queued rather than rushed into a release, because this is verification code.
|
|
104
|
+
Your base commit is likewise persisted nowhere, so `commit-guard` stays fingerprint-only.
|
|
105
|
+
|
|
106
|
+
**What IS gone** is the false green that needs no second process and nobody trying: evidence that
|
|
107
|
+
simply predates your edit. That is the case that was observed live, and it is pinned by a regression
|
|
108
|
+
test that was watched failing before the fix existed.
|
|
109
|
+
|
|
110
|
+
|
|
7
111
|
## 4.2.0 — a search whose pattern contains `>` no longer has to ask (AD-080)
|
|
8
112
|
|
|
9
113
|
**Read this if you have ever approved `grep -rn "=>" src`.** 4.1.0 explained why that prompt cannot
|
package/README.md
CHANGED
|
@@ -239,7 +239,7 @@ file), or run the guarded `/agent-workflow-kit uninstall`.
|
|
|
239
239
|
| `/agent-workflow-kit sandbox-masks` | any time | **cosmetic exclude lane for sandbox device masks** — an OS sandbox (Claude Code) injects character-device masks into the work tree as untracked `git status` noise; the review domain already ignores them **by construction** (never-committable untracked classes — char/block devices, FIFOs, sockets — are excluded from the fingerprint, the assembled review payload, and the clean checks). This mode hides them from `git status` too: flagless = read-only probe (derives the CURRENT mask set from the unfiltered walk + lstat — never a frozen list — and revalidates fenced entries, loudly flagging one that became a real path); `--apply` = consent-gated FULL-BLOCK replace of its own fenced block in `git rev-parse --git-path info/exclude` (stale masks drop by construction; `--clear` always means REMOVE the block — it takes precedence over the derivation). Writes ONLY its fence — never `.gitignore`, never global config; symlinked/non-regular exclude paths and malformed fences fail closed. Watch note: a real file at an excluded path is silently skipped by bulk staging (`git add -A`/`git add .`) — delete the stale line first; the probe flags exactly this case. |
|
|
240
240
|
| `/agent-workflow-kit grounding` | any time | **grounded-review facts assembler** — mechanizes populating `agy-review --facts @f`: slices your entry-point's **Hard Constraints** section verbatim (exactly one match, else a loud stop) and/or a plan's decision-bearing sections (`## Approach` + `## Verification` required, `## Decisions (locked)` when present; duplicates stop), under the same byte budget the agy wrapper enforces (minus `--reserve-bytes` for the artifact share), with a loud tail-trim on overflow. `--autonomy` (AD-044) appends the COMPUTED effective autonomy policy from the git-top `docs/ai/autonomy.json` (every red-line + per-activity level, stated source line; absent file → the computed defaults ARE the policy, exit 0; a malformed policy fails CLOSED, exit 1). Prints to stdout; `--out` writes **one scratch file only** — system-temp outside the repo ($TMPDIR / /tmp, rewritable) or a **fresh** gitignored in-repo path (create-only, exclusive write; an existing in-repo file, even gitignored, is refused — the `.env` clobber class); tracked, not-ignored-in-repo, other outside-repo, and symlink/non-regular destinations are all refused. Never commits, never runs a subscription CLI. |
|
|
241
241
|
| `/agent-workflow-kit core-evidence` | any time | **the ONE loop-evidence writer** (strip-the-kit) — every core evidence record lands in a single append-only JSONL store inside the git dir (never committable; versioned schema, latest-per-key supersession, byte-identical duplicates refused, malformed lines fail every reader closed). `red-proof "<file>#<pattern>"` declares an observed-red **BEFORE a bugfix** (N/N red runs + content custody + base + the pre-fix fingerprint; green/mixed/timeout are DISTINGUISHED refusals — nothing written); `degrade --backend --reason` is the ONLY escape for an unavailable review backend (per-tree, never all backends); `summary` renders the whole loop state statelessly (gate result · per-backend verdicts · red-proofs · degrades) — no ledger, no rounds, nothing remembered. Honest residual: records are forgeable — self-discipline, not a security boundary. Never commits, never runs a subscription CLI. |
|
|
242
|
-
| `/agent-workflow-kit coverage-check` | any time | **the final-run checker** (D3(c)+(d)) — reads the lcov the declared `unit-tests` gate produced at the FIXED git-dir path and fails on any uncovered CHANGED executable Node line (listed `file:line`; a changed file absent from the map is a file-level red; out-of-domain/unsupported files are LISTED — the claim narrowed honestly); VERIFIES every current-base red-proof declaration (bound test exists · custody hash unchanged · green N/N now · pre-fix fingerprint differs); prints `lcov-sha256=<hex|none>` of the exact bytes it consumed — the sha the `--final` receipt binds and re-hashes. An absent lcov is a LOUD `skipped-no-lcov`; a symlinked path is a refusal. `--check` is the gate exit code — declare it as the LAST gate (`run-gates --final` refuses otherwise). Read-only. |
|
|
242
|
+
| `/agent-workflow-kit coverage-check` | any time | **the final-run checker** (D3(c)+(d)) — **certifies coverage ONLY inside the `--final` run that owns the lcov** (ownership is exclusive by CONVENTION over the fixed path, not enforced — a concurrent writer to it is a stated residual, queued as LCOV-EXCLUSIVE-OWNERSHIP): an artifact on disk proves nothing about the tree it came from, so a standalone run prints its findings and states `attested=no` / `NO VERDICT` rather than a PASS (an lcov that predates an edit would otherwise certify a line the suite never executed). The runner passes a nonce whose one-way commitment over `{nonce, fingerprint, base}` is the `final-start.attempt` it recorded; a context describing another tree, or matching no recorded attempt, is a REFUSAL, never a verdict. Findings are unchanged — reads the lcov the declared `unit-tests` gate produced at the FIXED git-dir path and fails on any uncovered CHANGED executable Node line (listed `file:line`; a changed file absent from the map is a file-level red; out-of-domain/unsupported files are LISTED — the claim narrowed honestly); VERIFIES every current-base red-proof declaration (bound test exists · custody hash unchanged · green N/N now · pre-fix fingerprint differs); prints `lcov-sha256=<hex|none>` of the exact bytes it consumed — the sha the `--final` receipt binds and re-hashes. An absent lcov is a LOUD `skipped-no-lcov`; a symlinked path is a refusal. `--check` is the gate exit code — declare it as the LAST gate (`run-gates --final` refuses otherwise). Read-only. |
|
|
243
243
|
| `/agent-workflow-kit commit-guard` | any time | **the read-only pre-commit guard** (D10) — makes the commit capture the whole current working tree, so «verified» and «about to be committed» are the same bytes (the receipt itself has a stated residual — see the mode doc). FIRST it refuses an **INDEX that lags the verified working tree** (the gates and the fingerprint describe the WORKING tree while `git commit` takes the INDEX alone, and the fingerprint cannot tell them apart — so a lagging index used to ship a strict subset of what was verified): unstaged tracked paths or reviewable untracked-not-ignored paths, named up to a bounded cap with the remainder stated, a dirty tracked **submodule** named separately with its own recovery, and fail-closed on an undecidable probe. This deliberately blocks a partial commit. Then it binds the LATEST completed `run-gates --final` receipt to the EXACT current tree: refuses on a missing/red/stale receipt, fingerprint drift under the run, a dangling later attempt, declaration content drift, evidence-hash or lcov drift, or unsatisfied review obligations (the same review-state decision, recomputed over a sanitized env — forged out-of-repo stores never satisfy). Re-runs NO gate or test. Wire it into `.git/hooks/pre-commit` (the installer writes the RESOLVED invocation). `git commit --no-verify` stays the stated residual. |
|
|
244
244
|
| `/agent-workflow-kit recommendations` | any time (every `upgrade` ends with it) | **read-only deployment advisor** (AD-044) — computes what in THIS deployment is configured sub-optimally (allowlist not seeded, autonomy render drifted, OS sandbox unavailable, gates undeclared, bridge friction, sandbox-mask clutter, an unacknowledged sandbox recipe) and renders **verdict-first**: one composed verdict line (does anything need attention?), then each item as **{severity · what · one-line benefit · an optional `recipe:` line (the sandbox-lane live recipe, the worktrees-dir hand-apply-first grant advice, or the agents hidden-mode reconcile follow-up) · the exact consent-gated apply one-liner}**. The agent PRESENTS the section in the user's conversational language — every fact and count, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; raw tool block on request — and runs EXACTLY the rendered one-liners only on your yes, surfacing each item's posture note first. Renders **present-even-when-empty** (`no recommendations — flow optimal.`); a failed probe degrades to a stated skip line. Registry strings are fact-true frozen one-line data (posture/risk notes live in the mode doc at the consent moment); the kit never seeds `sandbox.network.allowedDomains` / `filesystem.allowWrite` (**HAND-APPLY** territory), and the sandbox-lane item's convergence is a neutral fingerprint acknowledgement recorded by a consent-gated ack writer into `docs/ai/acks.json` — never a security key (the recipe is documented per bridge in `capability.json` `networkHosts` + `writableDirs`). `--cwd` is required (the target project is explicit); never writes, never commits, never runs a subscription CLI. |
|
|
245
245
|
| `/agent-workflow-kit doc-parity` | any time | **read-only doc-parity lint** (AD-049) — kills the doc-drift class where a mode-contract doc silently lags a code constant (a `--check` doc still reading `300` after the diff cap moved to `400`): a **closed, exported registry** binds each live constant (review caps, schema versions, the ledger's own class/scope vocabulary, and the autonomy-doctor EXIT/status/trusted-dir contract) to the exact token its `references/modes/*.md` contract must carry, and asserts the CURRENT value renders into every bound file — a drifted doc, an unreadable file, or an absent token **fails closed**. The values are sourced from the live imports (never re-typed), so the lint can't itself go stale; adding a binding is adding a checked entry (closed-world, edit-safe). `--check` is a gate exit code for `docs/ai/gates.json`. Never writes, never commits, never runs a subscription CLI. |
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-kit
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '4.
|
|
6
|
+
version: '4.4.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -45,6 +45,12 @@
|
|
|
45
45
|
// (d) everything else → NO decision: exit 0, no output — the normal permission flow proceeds
|
|
46
46
|
// unchanged. The hook NEVER emits `deny`.
|
|
47
47
|
//
|
|
48
|
+
// EVERY ASK ALSO CARRIES A CALLER-FACING HINT (`additionalContext`). The ask itself goes to the
|
|
49
|
+
// HUMAN and its reason never reaches the caller that composed the command — so the guard knew the
|
|
50
|
+
// right lane and could not say it, which is the one thing every withdrawn deny design was buying.
|
|
51
|
+
// The hint is that channel without the refusal, and a host that ignores the field degrades to
|
|
52
|
+
// silence, i.e. to the previous behaviour. See the note above `HINT_CORE`.
|
|
53
|
+
//
|
|
48
54
|
// WHY THERE IS NO DENY RUNG, AND WHY THE DIRECTION IS RETIRED (kit 4.0.0 built one, AD-078
|
|
49
55
|
// removed it; the class was closed from the other side instead). The withdrawn rung refused a
|
|
50
56
|
// seeded read-only command that provably DISCARDED its output, on the argument that such a refusal
|
|
@@ -272,7 +278,16 @@ export const matchSeededCorePrefix = (command) => {
|
|
|
272
278
|
// review killed it: it would pull ANY unrelated `repo-search.mjs` into rung (b2), turning a
|
|
273
279
|
// pre-existing NO decision into an ASK — a change to a decision path this release has no business
|
|
274
280
|
// touching.
|
|
275
|
-
|
|
281
|
+
// The RECOVERY is per tool, not a constant. When repo-search was the only entry the reason named the
|
|
282
|
+
// literal `--pattern-file`, and that was wrong the moment a second tool joined: wrong for a tool with
|
|
283
|
+
// no such flag, and already incomplete for repo-search itself once its TARGET half gained a lane. An
|
|
284
|
+
// ask whose named recovery does not exist is worse than an ask with no advice, so the advice is
|
|
285
|
+
// derived from whichever tool matched.
|
|
286
|
+
export const SCANNED_TOOL_LANES = Object.freeze({
|
|
287
|
+
'agent-workflow-kit/tools/repo-search.mjs': '--pattern-file / --paths-file',
|
|
288
|
+
'agent-workflow-kit/tools/path-inventory.mjs': '--paths-file',
|
|
289
|
+
});
|
|
290
|
+
export const SCANNED_TOOL_PATHS = Object.freeze(Object.keys(SCANNED_TOOL_LANES));
|
|
276
291
|
|
|
277
292
|
// Quotes stripped and separators canonicalised, so a relative, absolute, quoted or Windows-separated
|
|
278
293
|
// spelling all compare the same. SUBSTRING, not equality, and scanned across EVERY token rather than
|
|
@@ -282,10 +297,13 @@ export const SCANNED_TOOL_PATHS = Object.freeze(['agent-workflow-kit/tools/repo-
|
|
|
282
297
|
// surface this exists to cover, while a spurious match merely over-asks — so inclusiveness wins.
|
|
283
298
|
const canonicalToken = (token) => token.replace(/["']/gu, '').replace(/\\/gu, '/');
|
|
284
299
|
|
|
285
|
-
export const matchScannedToolPrefix = (command) =>
|
|
300
|
+
export const matchScannedToolPrefix = (command) => matchScannedTools(command)[0] ?? null;
|
|
301
|
+
|
|
302
|
+
// ALL matches, not just the first. A command naming two scanned tools would otherwise be advised
|
|
303
|
+
// about whichever happens to sit earlier in the registry, and the residual may belong to the other.
|
|
304
|
+
export const matchScannedTools = (command) => {
|
|
286
305
|
const tokens = tokenizeCommand(command).map(canonicalToken);
|
|
287
|
-
|
|
288
|
-
return hit ?? null;
|
|
306
|
+
return SCANNED_TOOL_PATHS.filter((path) => tokens.some((token) => token.includes(path)));
|
|
289
307
|
};
|
|
290
308
|
|
|
291
309
|
// String-level, conservative: the hook sees the PRE-SHELL command string, so every class is a raw
|
|
@@ -381,6 +399,35 @@ export const isReadLaneCommand = (command) => {
|
|
|
381
399
|
});
|
|
382
400
|
};
|
|
383
401
|
|
|
402
|
+
// ── the caller-facing hint (rides an ask; refuses nothing) ────────────────────────────
|
|
403
|
+
//
|
|
404
|
+
// WHY THIS EXISTS, AND WHY IT IS NOT A DENY. An ask is answered by the HUMAN: `permissionDecisionReason`
|
|
405
|
+
// is context for THEIR dialog and never reaches the caller that composed the command. So for the whole
|
|
406
|
+
// life of this guard it has known the right lane and had no way to say it — which is the single reason
|
|
407
|
+
// every withdrawn deny design looked attractive, since a deny is refusal-shaped and does reach the
|
|
408
|
+
// caller. `additionalContext` is the same channel without the refusal. Probed live on 2026-07-28 with
|
|
409
|
+
// a throwaway hook: a distinct marker was delivered to the model on `allow`, on `ask`, AND on no
|
|
410
|
+
// decision at all.
|
|
411
|
+
// FAIL-SAFE BY CONSTRUCTION: a host that does not honour the field simply drops it, which is exactly
|
|
412
|
+
// the behaviour that existed before — silence. Nothing is refused, so a hint that is wrong for a
|
|
413
|
+
// particular command costs the caller a sentence, never their work. That is the property no deny
|
|
414
|
+
// design could ever claim.
|
|
415
|
+
export const HINT_CORE = 'agent-workflow: this read left the promptless lane — a plain read-only command with no redirection, no banner and no quotes stays inside it. If the byte is a decoration you do not need, drop it and re-issue; if it is ordinary text inside an argument, a search belongs in `repo-search.mjs` (`--pattern-file`), and several path questions (exists / size / line count / listing / small-file contents) are ONE `path-inventory.mjs` call. If the byte is a deliberate operator, keep it and confirm by hand.';
|
|
416
|
+
|
|
417
|
+
// The hint must not GUESS which of the two causes produced the residual — the guard cannot tell a
|
|
418
|
+
// byte that is ordinary text inside an argument from a real shell operator, and pretending otherwise
|
|
419
|
+
// is the exact claim AD-079 closed. So it names BOTH recoveries and lets the caller pick: the one
|
|
420
|
+
// that fits is always present, and the one that does not costs a clause.
|
|
421
|
+
// The hint never tells the caller to delete an operator it cannot read the intent of: dropping a
|
|
422
|
+
// real `> report.json` would change what the command DOES, and a guard that silently edits meaning is
|
|
423
|
+
// the failure this whole area was built to avoid. It names the out-of-band lane, notes that these
|
|
424
|
+
// tools already bound and print their own result (so a decoration is usually redundant), and leaves a
|
|
425
|
+
// deliberate operator to the human confirmation that already exists.
|
|
426
|
+
const hintForScannedTools = (tools) => {
|
|
427
|
+
const lanes = tools.map((tool) => `\`${tool}\` → ${SCANNED_TOOL_LANES[tool]}`).join('; ');
|
|
428
|
+
return `agent-workflow: the residual guard stopped this. If the byte is part of an ARGUMENT, pass it out of band — ${lanes} — and its bytes then never enter the command string, so this guard has nothing to scan (whether the clean form still prompts depends on your settings allow rules, which this hook does not read). If the byte is a decoration you do not need, drop it: these tools bound and print their own result. If it is a deliberate operator, keep it and confirm by hand — that is what the prompt is for.`;
|
|
429
|
+
};
|
|
430
|
+
|
|
384
431
|
// ── the decision ladder (pure core) ───────────────────────────────────────────────────
|
|
385
432
|
|
|
386
433
|
export const decideBashCall = ({ command, permissionMode, cwdIsProjectRoot, gates, readLaneOn = false }) => {
|
|
@@ -404,19 +451,23 @@ export const decideBashCall = ({ command, permissionMode, cwdIsProjectRoot, gate
|
|
|
404
451
|
return {
|
|
405
452
|
permissionDecision: DECISION_ASK,
|
|
406
453
|
permissionDecisionReason: `agent-workflow residual guard: read-only-seeded "${corePrefix}" carries ${residualClasses.join(' + ')} — a settings allow rule cannot see runtime shape; confirm by hand`,
|
|
454
|
+
additionalContext: HINT_CORE,
|
|
407
455
|
};
|
|
408
456
|
}
|
|
409
457
|
}
|
|
410
458
|
// (b2) the same guard over the scanned kit tools. The refusal NAMES the lane that avoids it, so a
|
|
411
459
|
// caller who picked the inline lane for a shell-significant pattern is corrected by the mechanism
|
|
412
460
|
// rather than expected to have remembered the rule.
|
|
413
|
-
const
|
|
414
|
-
if (
|
|
461
|
+
const scannedTools = matchScannedTools(trimmed);
|
|
462
|
+
if (scannedTools.length > 0) {
|
|
415
463
|
const residualClasses = detectResidualClasses(trimmed);
|
|
416
464
|
if (residualClasses.length > 0) {
|
|
465
|
+
const named = scannedTools.map((tool) => `"${tool}"`).join(' + ');
|
|
466
|
+
const lanes = [...new Set(scannedTools.map((tool) => SCANNED_TOOL_LANES[tool]))].join(' / ');
|
|
417
467
|
return {
|
|
418
468
|
permissionDecision: DECISION_ASK,
|
|
419
|
-
permissionDecisionReason: `agent-workflow residual guard:
|
|
469
|
+
permissionDecisionReason: `agent-workflow residual guard: ${named} carries ${residualClasses.join(' + ')} — pass the byte-carrying argument out of band with ${lanes} (its bytes then never enter the command string), or confirm by hand if the byte is really part of the invocation`,
|
|
470
|
+
additionalContext: hintForScannedTools(scannedTools),
|
|
420
471
|
};
|
|
421
472
|
}
|
|
422
473
|
}
|
|
@@ -6,6 +6,7 @@ The **final-run checker** (strip-the-kit D3(c)+(d)) — two deterministic arms o
|
|
|
6
6
|
|
|
7
7
|
Run `node ${CLAUDE_SKILL_DIR}/tools/coverage-check.mjs --check [--cwd <dir>]`:
|
|
8
8
|
|
|
9
|
+
0. **Attestation precondition (the provenance arm).** An lcov on disk carries no evidence of the tree it came from, so reading one and issuing a verdict certifies whatever happens to be there — the false GREEN direction is the dangerous one, because a line appended AFTER the suite ran has no `DA` entry and therefore reads non-executable ("nothing to cover"). Provenance is a CONSEQUENCE in exactly one context: a `run-gates --final` run deletes the artifact before any gate spawns. That runner mints a random nonce and writes `final-start.attempt` as a ONE-WAY COMMITMENT over `{nonce, tree fingerprint, base}`; the raw nonce rides the environment to this child, which recomputes the commitment and requires the record to carry it. Neither half suffices alone — a bare nonce is unverifiable, a persisted attempt id is reconstructible from public repo state — and the commitment is also the only place the BASE is bound, since no record stores it. The raw nonce is stripped from the red-proof probe environment so no descendant retains a live capability. Outcomes: **attested** → the coverage verdict is issued; **no handshake** → `attested=no` + `NO VERDICT` (exit 0, findings still printed, uncovered lines still exit 1 — the findings contract is unchanged); **a handshake describing another tree or matching no recorded attempt** → `REFUSED` (exit 1), never a verdict in either direction. One fully anchored `coverage-check: attested=<yes|no>` machine line rides every run, on the same exactly-once contract as the sha line. Stated residuals, both named rather than implied: (a) an operator who runs both processes can forge the store or the code — the kit's standing self-discipline posture, not a security boundary; (b) **"the run owns the artifact" is exclusive by CONVENTION over the fixed path, not enforced** — a writer outside the run (a second `run-gates`, a hand-run `--only unit-tests`, an orphaned test process) can place foreign evidence between the deletion and the checker's read, and every check then agrees. Closing (b) needs an attempt-unique artifact path, which the runner can name but the declared producer cmd must reference — queued as LCOV-EXCLUSIVE-OWNERSHIP. What this arm removes is the false green that needs no second process and nobody trying: evidence that predates the edit.
|
|
9
10
|
1. **Coverage arm (D3(d)):** every CHANGED executable Node line (`.mjs`/`.cjs`/`.js`, tracked working-vs-HEAD changes + untracked-not-ignored files) must be covered — uncovered lines are LISTED `file:line` and fail; a changed file ABSENT from the lcov map is a file-level red (never "non-executable" by silence); changed out-of-domain files (e.g. `.sh`) and unsupported-source files (e.g. `.ts`) are LISTED — the claim is narrowed honestly, not widened. NO lcov file at the path = a LOUD `skipped-no-lcov` (exit 0, stated — produce the file via the unit-tests gate's lcov reporters); a symlink at the path is a refusal (lstat, no-follow).
|
|
10
11
|
2. **Red-proof arm (D3(c)):** every authoritative current-base `red-proof` declaration must verify — the bound test file exists (deleted fails), its content sha256 matches the declaration (custody), the test resolves (zero-match fails) and runs green N/N NOW, and the declaration's pre-fix fingerprint differs from the current tree (equal = reuse/forgery, refused). A malformed evidence store fails CLOSED.
|
|
11
12
|
|
|
@@ -33,11 +33,12 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/velocity-profile.mjs [--dry-run | --apply] [
|
|
|
33
33
|
- `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd ${PROJECT_ROOT}` (wildcard — the read-only deployment advisor; its rendered apply one-liners are writers and still prompt)
|
|
34
34
|
- `node ${CLAUDE_SKILL_DIR}/tools/manifest/validate.mjs --strict <skill-dir>` (wildcard)
|
|
35
35
|
- `node ${CLAUDE_SKILL_DIR}/tools/release-scan.mjs <path>` (wildcard)
|
|
36
|
-
- `node ${CLAUDE_SKILL_DIR}/tools/repo-search.mjs --pattern <literal> --path <p>` (wildcard — the LITERAL search lane; for a pattern carrying a shell-significant byte use `--pattern-file <p>` instead,
|
|
36
|
+
- `node ${CLAUDE_SKILL_DIR}/tools/repo-search.mjs --pattern <literal> --path <p>` (wildcard — the LITERAL search lane; for a pattern carrying a shell-significant byte use `--pattern-file <p>` instead, and for a TARGET PATH carrying one use `--paths-file <p>` — either way the bytes never enter the command string. one of the TWO tier tools that carry hook residual coverage — see the honesty note below)
|
|
37
|
+
- `node ${CLAUDE_SKILL_DIR}/tools/path-inventory.mjs --path <p>` (wildcard — the read-only inventory lane: exists / type / size / `wc -l` lines / one-level listing / small-file contents for N paths in ONE call, so small path questions never need a composed shell. `--paths-file <p>` is its out-of-band target lane; hook residual coverage as with repo-search)
|
|
37
38
|
- `node ${CLAUDE_SKILL_DIR}/tools/run-gates.mjs --cwd ${PROJECT_ROOT}` — **EXACT byte-string only**, and honestly **project-exec, not read-only**: it runs YOUR declared `docs/ai/gates.json` commands — the same trust boundary the opt-in hook grants byte-exact per-cmd. A wildcard would be BROADER than that boundary (`--cwd <dir>` executes another project's declared gates), so the bare cwd-defaulting form, any other `--cwd`, `--only`, and **`--final`** forms all still prompt (`--final` WRITES the final-run receipt into the core-evidence store — a recording run is never auto-approved).
|
|
38
39
|
- Writer previews, **exact arg-free dry-run byte-strings only** (the SEEDED tier byte-string is the arg-free preview of each): `node ${CLAUDE_SKILL_DIR}/tools/velocity-profile.mjs` · `node ${CLAUDE_SKILL_DIR}/tools/cheap-agents.mjs` · `node ${CLAUDE_SKILL_DIR}/tools/gate-hook.mjs` — every `--apply`/`--write`/`--yes` still prompts, always. (`gate-hook` also has a **`--read-lane`** flagged preview — the opt-in read-only compound lane, `${CLAUDE_SKILL_DIR}/references/modes/hook.md`; that flagged form is NOT the seeded arg-free byte-string, so it may **prompt once** — it IS a consent flow, stated, no silent cap.)
|
|
39
40
|
|
|
40
|
-
Honesty notes: tier entries get **NO PreToolUse-hook residual coverage — with
|
|
41
|
+
Honesty notes: tier entries get **NO PreToolUse-hook residual coverage — with TWO deliberate exceptions, `repo-search.mjs` and `path-inventory.mjs`**, whose prefixes are in the hook's scanned list because they take caller-supplied argument bytes: a redirection or substitution on THEIR invocation raises the ask, whose reason names that tool's out-of-band lane (`--pattern-file` / `--paths-file`) — context for the HUMAN deciding, not a message to the caller. Neither is in the seeded core, so neither inherits the read-lane allow. Every other tier entry: the opt-in hook's residual ask-net guards only the seeded read-only CORE prefixes, so the tier rides the same settings-level residual posture as the core (redirection / command substitution are not inspectable at the settings layer; see the residual notice). A skill or project path that cannot survive UNQUOTED in a byte-exact rule (spaces, metacharacters, non-POSIX) **STOPs the tier up front with a clear error** — nothing is seeded. Anything you want covered beyond the tier — such paths, this repo's own relative-path spellings, other tools — stays a **BY-HAND add** to your settings, with the path your project actually reaches the kit by. Pre-existing `node …` allow entries that do NOT match the seeded tier byte-forms stay flagged by the advisory for hand review.
|
|
41
42
|
|
|
42
43
|
**Invariants:** creates `.claude/` if absent and writes **only** `.claude/settings.json` (no other file); **never** allowlists commit/push/publish; **never** writes `settings.local.json`; never commits; opt-in `acceptEdits`, never silent.
|
|
43
44
|
|
|
@@ -1,30 +1,31 @@
|
|
|
1
1
|
### Command shapes — the promptless bar for instructed reads & probes
|
|
2
2
|
|
|
3
|
-
When a mode doc tells you to read a file or probe
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
When a mode doc tells you to read a file or probe state WITHOUT prescribing the exact command (a
|
|
4
|
+
recon read, the version-stamp read, any "check X"), the shape is yours — and improvised shapes are
|
|
5
|
+
where approval prompts come from. The bar:
|
|
6
6
|
|
|
7
|
-
- **Reads ride the host's file-read tool** (Read/Grep/Glob
|
|
8
|
-
|
|
7
|
+
- **Reads ride the host's file-read tool** (Read/Grep/Glob, or your agent's equivalent) whenever one
|
|
8
|
+
exists — a file-read tool never fires a shell approval prompt.
|
|
9
9
|
- **No file-read tool → ONE plain undecorated command per probe:** no `;`/`&&` compounds, no
|
|
10
|
-
redirects, no pipes, no command substitution — one probe per invocation
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
- **Improvised file writes ride the host's file-edit tools** (Write/Edit or the equivalent) —
|
|
18
|
-
never an ad-hoc heredoc or shell-redirect write.
|
|
10
|
+
redirects, no pipes, no command substitution — one probe per invocation (the lens’s «plain pipeline
|
|
11
|
+
per call», stricter). A decorated or chained form is outside this contract’s guarantee: behavior is
|
|
12
|
+
host/config-dependent — it may prompt, an opt-in read-lane may auto-approve a vetted compound, and
|
|
13
|
+
command substitution may even slip past a prefix allow rule. The plain single-command shape is the
|
|
14
|
+
only shell fallback that is promptless by construction.
|
|
15
|
+
- **Improvised file writes ride the host's file-edit tools** (Write/Edit or equivalent) — never an
|
|
16
|
+
ad-hoc heredoc or shell-redirect write.
|
|
19
17
|
- **Searching for TEXT is its own case.** A pattern carrying `>`, `` ` `` or `$(` prompts on a
|
|
20
|
-
seeded-core command however
|
|
21
|
-
|
|
22
|
-
one; else,
|
|
23
|
-
switching to `--pattern-file <path>` for a byte-carrying pattern
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
the
|
|
18
|
+
seeded-core command however quoted — the guard scans the raw string and a quote-stripped copy.
|
|
19
|
+
Quoting is not a workaround. (`|`/`&&` do not trip it.) Use the host's search tool if it has
|
|
20
|
+
one; else, with the kit tier seeded, `node <kit>/tools/repo-search.mjs --pattern <literal>`,
|
|
21
|
+
switching to `--pattern-file <path>` for a byte-carrying pattern and `--paths-file <path>` for a
|
|
22
|
+
byte-carrying TARGET — written with the file-write tool above, so their bytes never enter the
|
|
23
|
+
command string; else one plain command, taking the prompt. A wrong lane earns an ask naming the
|
|
24
|
+
right lane back to YOU, not just the human. **PATH questions too:** exists / size / lines / listing
|
|
25
|
+
(file text needs `--contents`), any number of paths, is ONE
|
|
26
|
+
`node <kit>/tools/path-inventory.mjs --path <p>` — not a bannered compound. Residual: a bare `grep` still prompts, and the file lanes need
|
|
27
|
+
promptless writes.
|
|
27
28
|
|
|
28
|
-
**Scope — improvised shapes only.** The
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
**Scope — improvised shapes only.** The commands a mode doc itself prescribes (`node …/tools/…`
|
|
30
|
+
dispatch lines, `--apply` lanes, install/symlink steps) are OUTSIDE this contract: run them exactly
|
|
31
|
+
as prescribed, as plain single invocations.
|
package/tools/coverage-check.mjs
CHANGED
|
@@ -117,6 +117,68 @@ export const keyFor = (rootTop, rel) => {
|
|
|
117
117
|
}
|
|
118
118
|
};
|
|
119
119
|
|
|
120
|
+
// ── the attestation handshake (the provenance precondition) ───────────────────────────────────────
|
|
121
|
+
// An LCOV on disk carries NO evidence of the tree it was produced from, so reading one and issuing
|
|
122
|
+
// a verdict certifies whatever happens to be there. The STALE-FAILURE direction was observed live
|
|
123
|
+
// (2026-07-27, an identical failure list and sha after tests were added); the FALSE GREEN — the
|
|
124
|
+
// dangerous one — was reproduced HERMETICALLY: a line appended after the suite ran has no DA entry,
|
|
125
|
+
// and lcov.mjs:9-13 states that reads as non-executable, i.e. "nothing to cover".
|
|
126
|
+
//
|
|
127
|
+
// Provenance is a CONSEQUENCE inside exactly one context: a `run-gates --final` run deletes the
|
|
128
|
+
// artifact before any gate spawns, so anything present came from that run. The runner mints a random
|
|
129
|
+
// nonce and writes `final-start.attempt` as a one-way COMMITMENT over {nonce, fingerprint, base};
|
|
130
|
+
// the raw nonce rides the environment to this child, which recomputes the commitment and requires
|
|
131
|
+
// the record to carry it. Neither half suffices alone: a bare nonce is unverifiable (any later value
|
|
132
|
+
// would do), a persisted attempt id alone is replayable (it is reconstructible from public repo
|
|
133
|
+
// state, which is how the first two designs certified foreign evidence). The commitment also BINDS
|
|
134
|
+
// THE BASE, which is persisted nowhere else — the only way a base check is possible at all.
|
|
135
|
+
//
|
|
136
|
+
// Residual, stated: an operator who runs both processes can forge the store or the code. That is the
|
|
137
|
+
// kit's standing posture (review-state.mjs HUMAN residual), not a new weakening. What this removes
|
|
138
|
+
// is the ACCIDENTAL false green — an interrupted run plus an ordinary later test run, nobody trying.
|
|
139
|
+
export const ATTEST_NONCE_ENV = 'AW_FINAL_ATTEST_NONCE';
|
|
140
|
+
export const ATTEST_FINGERPRINT_ENV = 'AW_FINAL_ATTEST_FINGERPRINT';
|
|
141
|
+
export const ATTEST_BASE_ENV = 'AW_FINAL_ATTEST_BASE';
|
|
142
|
+
const ATTEST_ENV_VARS = Object.freeze([ATTEST_NONCE_ENV, ATTEST_FINGERPRINT_ENV, ATTEST_BASE_ENV]);
|
|
143
|
+
|
|
144
|
+
// The commitment bytes: newline-joined, which is unambiguous because every field is hex or empty
|
|
145
|
+
// (the nonce and fingerprint by construction, the base a git object id or '' on an unborn branch).
|
|
146
|
+
export const commitmentFor = (nonce, fingerprint, base) =>
|
|
147
|
+
createHash('sha256').update(`${nonce}\n${fingerprint}\n${base ?? ''}`).digest('hex');
|
|
148
|
+
|
|
149
|
+
// A child of this process must never inherit a LIVE capability: the red-proof arm spawns `node --test`
|
|
150
|
+
// probes, and a detached descendant holding the raw nonce could attest later.
|
|
151
|
+
export const withoutAttestEnv = (env) => {
|
|
152
|
+
const out = { ...env };
|
|
153
|
+
for (const key of ATTEST_ENV_VARS) delete out[key];
|
|
154
|
+
return out;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// → { attesting: true } | { attesting: false, reason } | { refusal } — a refusal is an exit-1
|
|
158
|
+
// identity failure (the runner handed a context that no longer describes this tree), DISTINCT from
|
|
159
|
+
// the ordinary non-attesting case, which is exit 0 and merely withholds the verdict.
|
|
160
|
+
// Exported as a test seam (the keyFor idiom): the undecidable-identity arm below cannot be reached
|
|
161
|
+
// through the CLI, where a resolvable work tree is a precondition of getting this far.
|
|
162
|
+
export const attestationState = ({ env, records, fingerprint, base }) => {
|
|
163
|
+
const nonce = env[ATTEST_NONCE_ENV];
|
|
164
|
+
if (typeof nonce !== 'string' || nonce === '') {
|
|
165
|
+
return { attesting: false, reason: 'no final-run attestation context — a verdict is issued only inside the run that owns the lcov (run-gates.mjs --final); findings below are informational' };
|
|
166
|
+
}
|
|
167
|
+
const passedFingerprint = env[ATTEST_FINGERPRINT_ENV] ?? '';
|
|
168
|
+
const passedBase = env[ATTEST_BASE_ENV] ?? '';
|
|
169
|
+
if (fingerprint == null) {
|
|
170
|
+
return { refusal: 'the attestation context cannot be verified — this tree has no computable fingerprint' };
|
|
171
|
+
}
|
|
172
|
+
if (passedFingerprint !== fingerprint || passedBase !== (base ?? '')) {
|
|
173
|
+
return { refusal: `the tree MOVED under the final run (the attestation context describes ${passedFingerprint.slice(0, 12)}…@${passedBase.slice(0, 12) || 'unborn'}, this tree is ${fingerprint.slice(0, 12)}…@${(base ?? '').slice(0, 12) || 'unborn'}) — a gate changed the working tree after the suite produced the lcov; re-run run-gates.mjs --final` };
|
|
174
|
+
}
|
|
175
|
+
const want = commitmentFor(nonce, fingerprint, base ?? '');
|
|
176
|
+
if (!records.some((r) => r.kind === 'final-start' && r.attempt === want)) {
|
|
177
|
+
return { refusal: 'the attestation context matches no recorded final-run attempt — the evidence store lost or never received the start record; re-run run-gates.mjs --final' };
|
|
178
|
+
}
|
|
179
|
+
return { attesting: true };
|
|
180
|
+
};
|
|
181
|
+
|
|
120
182
|
// ── the red-proof verification arm (D3(c)) ────────────────────────────────────────────────────────
|
|
121
183
|
|
|
122
184
|
// verifyRedProofs({ rootTop, cwd, env }) → { failures: [...], verified: n } | { storeFailure }.
|
|
@@ -175,6 +237,12 @@ export const runCheck = ({ cwd = process.cwd(), env = process.env } = {}) => {
|
|
|
175
237
|
const lcovPath = resolveLcovPath(cwd, env);
|
|
176
238
|
const lines = [];
|
|
177
239
|
let failed = false;
|
|
240
|
+
// The identity is read BEFORE the coverage arm and again AFTER it: a single up-front comparison
|
|
241
|
+
// leaves a window in which the tree moves between the surface walk and the verdict.
|
|
242
|
+
const identityBefore = { fingerprint: computeTreeFingerprint(cwd), base: resolveBase(cwd) };
|
|
243
|
+
const storePath = resolveEvidencePath(cwd, env);
|
|
244
|
+
const storeRecords = storePath ? readEvidence(storePath).records : [];
|
|
245
|
+
const attestBefore = attestationState({ env, records: storeRecords, ...identityBefore });
|
|
178
246
|
const cov = checkCoverage({ rootTop, lcovPath });
|
|
179
247
|
// The machine line the final-run receipt binds (M2): the sha of the exact bytes THIS check
|
|
180
248
|
// consumed — `none` states loudly that no lcov was read.
|
|
@@ -189,7 +257,8 @@ export const runCheck = ({ cwd = process.cwd(), env = process.env } = {}) => {
|
|
|
189
257
|
for (const f of cov.failures) lines.push(` ${f}`);
|
|
190
258
|
}
|
|
191
259
|
}
|
|
192
|
-
|
|
260
|
+
// The probes must never inherit a live attestation capability (a detached descendant could keep it).
|
|
261
|
+
const red = verifyRedProofs({ rootTop, cwd, env: withoutAttestEnv(env) });
|
|
193
262
|
if (red.storeFailure) {
|
|
194
263
|
failed = true;
|
|
195
264
|
lines.push(`coverage-check: FAIL — ${red.storeFailure}`);
|
|
@@ -202,7 +271,24 @@ export const runCheck = ({ cwd = process.cwd(), env = process.env } = {}) => {
|
|
|
202
271
|
lines.push(`coverage-check: ${red.verified} red-proof record(s) verified green N/N with custody intact`);
|
|
203
272
|
}
|
|
204
273
|
}
|
|
205
|
-
|
|
274
|
+
// Re-read the identity AFTER the coverage arm and re-decide: the attestation must describe the
|
|
275
|
+
// tree the verdict was actually computed over, not the one it started over.
|
|
276
|
+
const identityAfter = { fingerprint: computeTreeFingerprint(cwd), base: resolveBase(cwd) };
|
|
277
|
+
const attestAfter = attestationState({ env, records: storeRecords, ...identityAfter });
|
|
278
|
+
const attestation = attestBefore.refusal ? attestBefore : attestAfter;
|
|
279
|
+
const attesting = attestation.attesting === true && attestBefore.attesting === true;
|
|
280
|
+
// EXACTLY ONE fully anchored machine line, the lcov-sha256 contract's sibling — the runner binds
|
|
281
|
+
// it, so a missing/duplicated/injected one is an integrity failure rather than a silent green.
|
|
282
|
+
lines.push(`coverage-check: attested=${attesting ? 'yes' : 'no'}`);
|
|
283
|
+
if (attestation.refusal) {
|
|
284
|
+
failed = true;
|
|
285
|
+
lines.push(`coverage-check: REFUSED — ${attestation.refusal}`);
|
|
286
|
+
} else if (!attesting) {
|
|
287
|
+
lines.push(`coverage-check: NO VERDICT — ${attestation.reason}`);
|
|
288
|
+
}
|
|
289
|
+
// The attestation gates ONLY the coverage claim. Every pre-existing fail-closed refusal above
|
|
290
|
+
// (symlinked lcov, malformed evidence store, unmet red-proof obligation) keeps its own exit 1.
|
|
291
|
+
if (attesting && !failed && !cov.skipped && cov.failures.length === 0) {
|
|
206
292
|
lines.push('coverage-check: PASS — every changed Node line is covered');
|
|
207
293
|
}
|
|
208
294
|
return { code: failed ? 1 : 0, lines };
|
|
@@ -225,9 +311,21 @@ An absent lcov file is a LOUD skipped-no-lcov (exit 0 — NO coverage check ran,
|
|
|
225
311
|
symlinked lcov path, an uncovered line, a broken red-proof obligation, or a malformed evidence
|
|
226
312
|
store fails (exit 1).
|
|
227
313
|
|
|
314
|
+
A coverage VERDICT is issued ONLY inside the run that owns the artifact's lifetime: run-gates
|
|
315
|
+
--final deletes the lcov before any gate spawns and hands this checker an attestation context
|
|
316
|
+
(a nonce whose one-way commitment over {nonce, fingerprint, base} is the final-start attempt id).
|
|
317
|
+
One anchored machine line rides every run: coverage-check: attested=<yes|no>.
|
|
318
|
+
attested=yes → the verdict, exactly as before.
|
|
319
|
+
attested=no → NO VERDICT (exit 0): findings are still printed, uncovered lines still exit 1;
|
|
320
|
+
only the PASS attestation is withheld.
|
|
321
|
+
REFUSED (exit 1) → the context describes another tree, or matches no recorded attempt.
|
|
322
|
+
Residual, stated: ownership of the fixed path is CONVENTION, not enforcement — a concurrent writer
|
|
323
|
+
to it can still place foreign evidence (queued as LCOV-EXCLUSIVE-OWNERSHIP).
|
|
324
|
+
|
|
228
325
|
Sandbox-safe: no network; writes nothing; spawns read-only git queries and the bound-test
|
|
229
|
-
probes (node --test, shell-free) — the D4 sandbox lane.
|
|
230
|
-
|
|
326
|
+
probes (node --test, shell-free) — the D4 sandbox lane. The attestation variables are consumed and
|
|
327
|
+
removed from this process's environment before anything spawns, so no child inherits the capability.
|
|
328
|
+
Read-only. Exit codes: 0 pass / no-verdict / skipped-loud; 1 fail or REFUSED; 2 usage.`;
|
|
231
329
|
|
|
232
330
|
export const main = (argv, ctx = {}) => {
|
|
233
331
|
const env = ctx.env ?? process.env;
|
|
@@ -253,7 +351,17 @@ export const main = (argv, ctx = {}) => {
|
|
|
253
351
|
|
|
254
352
|
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
255
353
|
if (isDirectRun) {
|
|
256
|
-
|
|
354
|
+
// The capability is CONSUMED here: snapshot it, then remove it from this process's environment
|
|
355
|
+
// before anything spawns. Every `git` query and every bound-test probe below inherits
|
|
356
|
+
// process.env, so leaving it in place would hand a live attestation context to each of them —
|
|
357
|
+
// and a detached descendant could then certify a foreign lcov long after this run ended.
|
|
358
|
+
const attest = Object.fromEntries(
|
|
359
|
+
[ATTEST_NONCE_ENV, ATTEST_FINGERPRINT_ENV, ATTEST_BASE_ENV]
|
|
360
|
+
.filter((k) => process.env[k] !== undefined)
|
|
361
|
+
.map((k) => [k, process.env[k]]),
|
|
362
|
+
);
|
|
363
|
+
for (const k of Object.keys(attest)) delete process.env[k];
|
|
364
|
+
const r = main(process.argv.slice(2), { env: { ...process.env, ...attest } });
|
|
257
365
|
if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
|
|
258
366
|
if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
|
|
259
367
|
process.exitCode = r.code;
|