session-orchestrator 3.17.0 → 3.19.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +105 -412
- package/README.md +12 -9
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +244 -9
- package/docs/session-config-template.md +39 -3
- package/hooks/_lib/guard-source-loader.mjs +467 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +23 -0
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +5 -2
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -13
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +73 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- package/templates/_shared/loop.md +4 -4
|
@@ -0,0 +1,977 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* post-bash-write-verify.mjs — PostToolUse hook (matcher `Bash`): report
|
|
4
|
+
* working-tree changes a Bash call made OUTSIDE the wave's `allowedPaths`.
|
|
5
|
+
*
|
|
6
|
+
* ## Why this hook exists (#915, follow-up to #906 / #800)
|
|
7
|
+
*
|
|
8
|
+
* `hooks/enforce-scope.mjs` returns `emitAllow()` for every tool that is not
|
|
9
|
+
* `Edit` / `Write` / `MultiEdit`. A Bash call therefore never reaches a path
|
|
10
|
+
* check, and `echo x > out-of-scope.mjs` bypasses the entire scope-enforcement
|
|
11
|
+
* layer. The PreToolUse half of the fix (`bash-write-guard` in
|
|
12
|
+
* `hooks/enforce-commands.mjs`, #800) parses write targets out of the command
|
|
13
|
+
* string — which is heuristic, which is why it ships OFF by default.
|
|
14
|
+
*
|
|
15
|
+
* This hook is the non-heuristic complement: it does not guess what the command
|
|
16
|
+
* meant, it observes what the filesystem actually shows afterwards. That is why
|
|
17
|
+
* it can default to ENABLED where `bash-write-guard` cannot.
|
|
18
|
+
*
|
|
19
|
+
* ## Warn-only, by construction
|
|
20
|
+
*
|
|
21
|
+
* PostToolUse fires AFTER the command ran; there is nothing left to block. The
|
|
22
|
+
* hook writes one stderr line and one PostToolUse `additionalContext` string,
|
|
23
|
+
* and always exits 0. Escalation to a blocking guard is not a switch on THIS
|
|
24
|
+
* hook — it is flipping `enforcement-gates.bash-write-guard` to `true`, which
|
|
25
|
+
* this hook exists to supply the evidence for. See
|
|
26
|
+
* `docs/session-config-reference.md` § Bash-Write Verify.
|
|
27
|
+
*
|
|
28
|
+
* ## Decision flow (early-exit)
|
|
29
|
+
*
|
|
30
|
+
* G1 profile gate (`SO_HOOK_PROFILE` / `SO_DISABLED_HOOKS`)
|
|
31
|
+
* G2 tool_name === 'Bash'
|
|
32
|
+
* G3 wave-scope.json exists (disappearance after a recorded scope → notice)
|
|
33
|
+
* G4 gates['bash-write-verify'] !== false (absent ⇒ ENABLED)
|
|
34
|
+
* G5 enforcement !== 'off'
|
|
35
|
+
* G6 git status delta vs. snapshot → filter → report NEW out-of-scope paths
|
|
36
|
+
*
|
|
37
|
+
* G4/G5 are evaluated on the scope state RECORDED IN THE SNAPSHOT — the state
|
|
38
|
+
* in effect BEFORE the observed call — never on the live file (#938 vector 1):
|
|
39
|
+
* a Bash call that rewrites wave-scope.json to `enforcement: "off"` cannot
|
|
40
|
+
* silence the hook for its own evaluation. The rewrite itself is always
|
|
41
|
+
* reported (content-hash comparison against the snapshot, because the file is
|
|
42
|
+
* typically gitignored and thus invisible to `git status`), and the new value
|
|
43
|
+
* takes effect from the NEXT call on.
|
|
44
|
+
*
|
|
45
|
+
* With no TRUSTWORTHY snapshot, G4/G5 evaluate as `strict` (#944/#945) — never
|
|
46
|
+
* from the live file. The old live fallback read as a first-call convenience but
|
|
47
|
+
* was a one-command silencer: the sidecar path is `sha1(repoRoot)` and therefore
|
|
48
|
+
* exactly computable, so `rm -f` on it re-creates the "no snapshot" precondition
|
|
49
|
+
* ON DEMAND, not only on the genuine first call. One Bash call could delete the
|
|
50
|
+
* sidecar, write `enforcement: "off"`, and write out of scope — and be judged
|
|
51
|
+
* under the value it had just installed.
|
|
52
|
+
*
|
|
53
|
+
* "Trustworthy" is a statement about the record's CONTENT, not the file's
|
|
54
|
+
* existence. Keyed on `snapshot === null`, the #944 repair closed one state of
|
|
55
|
+
* four at unchanged attacker cost — `rm -f <path>` simply becomes `echo '{}' >
|
|
56
|
+
* <path>`, and `{}` / `{"scopeState":{"hash":"…"}}` were both back to total
|
|
57
|
+
* silence. `trustedScopeState()` requires a `SNAPSHOT_SCHEMA` marker this build
|
|
58
|
+
* writes plus a well-formed `scopeState`, so a forgery has to state the very
|
|
59
|
+
* value it wants unstated. Both effects of that condition — evaluating the call
|
|
60
|
+
* strict AND emitting the lost-state notice — ride the same predicate; splitting
|
|
61
|
+
* them restores the path report while silently dropping the tamper trail.
|
|
62
|
+
*
|
|
63
|
+
* ## Three properties that keep this from becoming noise
|
|
64
|
+
*
|
|
65
|
+
* 1. **Delta, not absolute.** A bare `git status` per Bash call reports the
|
|
66
|
+
* CUMULATIVE dirty tree: once one file is edited, every later Bash call in
|
|
67
|
+
* the session fires. Noise on that scale gets the guard switched off, which
|
|
68
|
+
* reproduces exactly the bypass pressure #915 fights. Only paths new
|
|
69
|
+
* relative to the previous invocation are reported.
|
|
70
|
+
* 2. **Report-once.** A reported path is folded into the snapshot, so a file
|
|
71
|
+
* written by ten consecutive `sed -i` calls warns once, not ten times.
|
|
72
|
+
* 3. **Re-baseline without amnesia (#938).** No snapshot yet (first Bash call
|
|
73
|
+
* of a session — or a deleted sidecar, vector 2), or a changed
|
|
74
|
+
* `allowedPaths` signature (new wave, vector 3): fold the current dirty set
|
|
75
|
+
* into the snapshot, but stay silent ONLY about paths whose mtime predates
|
|
76
|
+
* the wave-scope.json write. That dirt predates the wave; blaming the
|
|
77
|
+
* observed call for it is a false positive. Dirt NEWER than the scope file
|
|
78
|
+
* appeared during this wave — a re-baseline (which the observed actor can
|
|
79
|
+
* force by deleting the `$TMPDIR` sidecar) must not swallow it.
|
|
80
|
+
* 4. **A lost snapshot is named, not assumed benign (#945).** "Sidecar deleted"
|
|
81
|
+
* and "genuine first call" are bit-identical at the read (`readSnapshot()`
|
|
82
|
+
* returns null for both), so the difference must come from an out-of-band
|
|
83
|
+
* clock. Two are consulted: the wave-scope.json mtime (#938) and — because
|
|
84
|
+
* a `touch` on the scope file neutralises exactly that one — the session
|
|
85
|
+
* start time from `.orchestrator/current-session.json`. A legitimate first
|
|
86
|
+
* Bash call sits seconds-to-minutes after session start; a mid-session
|
|
87
|
+
* `rm -f` sits hours after it, and no `touch` moves that marker.
|
|
88
|
+
*
|
|
89
|
+
* ## Ignore list (contract, not implementation detail)
|
|
90
|
+
*
|
|
91
|
+
* `git status --porcelain` already omits `.gitignore`d files, which in THIS
|
|
92
|
+
* repo covers most sibling-hook writes. The explicit list below exists because
|
|
93
|
+
* the guard must not depend on a consumer repo's `.gitignore` being complete.
|
|
94
|
+
*
|
|
95
|
+
* | class | patterns |
|
|
96
|
+
* |--------------------------------|-----------------------------------------------------|
|
|
97
|
+
* | sibling-hook + own event writes| `.orchestrator/{metrics,debug,eval}/**`, `current-session.json`, `host.json`, `session.lock`, `state.lock`, `STATE.md` |
|
|
98
|
+
* | coordinator status files | `.claude|.codex|.cursor|.pi/{STATE.md,hooks.json,metrics/**,worktrees/**,*.lock}` |
|
|
99
|
+
* | package-manager artefacts | `node_modules/**`, `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lockb` |
|
|
100
|
+
* | build / coverage output | `coverage/**`, `dist/**`, `build/**`, `.next/**`, `junit.xml`, `*.log` |
|
|
101
|
+
* | tmp+rename residue | `*.tmp`, `*.tmp-*`, `*.tmp.*`, `.tmp.*` (io.mjs `writeJsonAtomicSync`, `post-tool-failure-corrective-context.mjs` `.tmp-ptf-*`) |
|
|
102
|
+
* | OS noise | `.DS_Store`, `Thumbs.db` |
|
|
103
|
+
*
|
|
104
|
+
* `wave-scope.json` is deliberately ABSENT from the list since #938: the
|
|
105
|
+
* scope-control file is the one path whose change must ALWAYS be surfaced. An
|
|
106
|
+
* ignore entry here plus a `.gitignore` entry made it doubly invisible, so the
|
|
107
|
+
* observed actor could rewrite the gate unreported. Because the file is
|
|
108
|
+
* typically gitignored, `git status` cannot see it anyway — change detection
|
|
109
|
+
* for it is content-hash-based against the snapshot (`formatControlNotice`).
|
|
110
|
+
*
|
|
111
|
+
* The hook's OWN snapshot lives in `$TMPDIR`, never inside the repo, so it is
|
|
112
|
+
* structurally incapable of reporting its own bookkeeping.
|
|
113
|
+
*
|
|
114
|
+
* ## Measured (2026-07-30, this repo, 1 507 tracked files)
|
|
115
|
+
*
|
|
116
|
+
* hook end-to-end, per Bash call 94.8 ms
|
|
117
|
+
* ├─ node cold start (every hook pays this) ~67 ms (enforce-commands.mjs: 66.9 ms)
|
|
118
|
+
* └─ marginal cost of the git status added here ~28 ms
|
|
119
|
+
* git … --porcelain -z --untracked-files=all 25.0 ms (=normal: 28.7 ms)
|
|
120
|
+
* corpus: 2 528 Bash calls across 41 of 51 archived transcripts
|
|
121
|
+
* → 732 calls (29.0 %) contain any write construct
|
|
122
|
+
* → 23 calls (0.91 %) wrote a real in-repo non-ignored path
|
|
123
|
+
* → per session: median 1 distinct path, max 5 (⇒ ~1 warning/session)
|
|
124
|
+
*
|
|
125
|
+
* ## stdout discipline
|
|
126
|
+
*
|
|
127
|
+
* The payload names changed files, so it is genuinely capable of growing past
|
|
128
|
+
* the 65 536-byte kernel pipe buffer. `console.log` + `process.exit()` would
|
|
129
|
+
* silently drop the tail on macOS (Node docs, "process I/O": pipes are async on
|
|
130
|
+
* macOS) — the #906 fail-open class. Two independent bounds apply: the payload
|
|
131
|
+
* is clamped (MAX_REPORTED_PATHS / MAX_CONTEXT_CHARS) and the write goes
|
|
132
|
+
* through `writeStdoutLineSync`, which loops `fs.writeSync(1, …)` to
|
|
133
|
+
* completion.
|
|
134
|
+
*
|
|
135
|
+
* ## PSA
|
|
136
|
+
*
|
|
137
|
+
* `git status` only. `--no-optional-locks` is load-bearing: without it `git
|
|
138
|
+
* status` opportunistically refreshes (and locks) `.git/index`, which races a
|
|
139
|
+
* parallel session's index write — PSA-007's whole concern. No git-write
|
|
140
|
+
* command is ever issued.
|
|
141
|
+
*/
|
|
142
|
+
|
|
143
|
+
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
144
|
+
// Exit 0 immediately (silent no-op) when disabled via profile/env (#211).
|
|
145
|
+
if (!shouldRunHook('post-bash-write-verify')) process.exit(0);
|
|
146
|
+
|
|
147
|
+
import path from 'node:path';
|
|
148
|
+
import os from 'node:os';
|
|
149
|
+
import { fileURLToPath } from 'node:url';
|
|
150
|
+
import { createHash } from 'node:crypto';
|
|
151
|
+
import { execFileSync } from 'node:child_process';
|
|
152
|
+
import { readFileSync, mkdirSync, writeFileSync, renameSync, realpathSync, statSync } from 'node:fs';
|
|
153
|
+
|
|
154
|
+
import { readStdin, writeStdoutLineSync } from '../scripts/lib/io.mjs';
|
|
155
|
+
import { resolveProjectDir } from '../scripts/lib/platform.mjs';
|
|
156
|
+
import { findScopeFile, pathMatchesPattern } from '../scripts/lib/hardening.mjs';
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Constants
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
/** Wall-clock ceiling for the `git status` child process. */
|
|
163
|
+
const GIT_TIMEOUT_MS = 3_000;
|
|
164
|
+
|
|
165
|
+
/** Ceiling for `git status` stdout (a pathological tree is not worth reporting). */
|
|
166
|
+
const GIT_MAX_BUFFER = 4 * 1024 * 1024;
|
|
167
|
+
|
|
168
|
+
/** Max paths named in the report; the rest collapse into a "+N more" tail. */
|
|
169
|
+
export const MAX_REPORTED_PATHS = 20;
|
|
170
|
+
|
|
171
|
+
/** Hard ceiling on the emitted `additionalContext` string. */
|
|
172
|
+
export const MAX_CONTEXT_CHARS = 1_500;
|
|
173
|
+
|
|
174
|
+
/** Snapshot entries retained; bounds the sidecar for a pathological tree. */
|
|
175
|
+
const MAX_SNAPSHOT_PATHS = 5_000;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Sidecar schema version, written by `writeSnapshot` on EVERY write and required
|
|
179
|
+
* by `trustedScopeState` before any recorded value is believed.
|
|
180
|
+
*
|
|
181
|
+
* Its job is not versioning — it is making the alt-schema exception forgeable
|
|
182
|
+
* only by writing the thing it wants to hide. The exception exists so that a
|
|
183
|
+
* sidecar left by an OLDER build (no `scopeState` at all) does not make every
|
|
184
|
+
* in-flight session emit once on deploy. Keyed on the ABSENCE of a field, that
|
|
185
|
+
* exception is free to an attacker: `echo '{}' > <sidecar>` reproduces the
|
|
186
|
+
* old-build shape exactly. Keyed on the PRESENCE of a marker only the current
|
|
187
|
+
* build writes, a forgery must supply `schema` AND a plausible `scopeState`
|
|
188
|
+
* containing the enforcement value — i.e. it must state the value it is trying
|
|
189
|
+
* to keep unstated.
|
|
190
|
+
*
|
|
191
|
+
* Cost of the bump: one strict-evaluated call (and, if a clock finds the state
|
|
192
|
+
* old, one advisory line) per repo on the deploy that lands it. Bump this
|
|
193
|
+
* whenever the recorded shape changes; never read a version other than this one
|
|
194
|
+
* as trustworthy.
|
|
195
|
+
*/
|
|
196
|
+
const SNAPSHOT_SCHEMA = 2;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* A missing snapshot while the WAVE SCOPE is at least this old is suspicious
|
|
200
|
+
* (#938 fix 3): a genuine first Bash call happens minutes after the coordinator
|
|
201
|
+
* wrote wave-scope.json, whereas a long-running wave whose sidecar suddenly
|
|
202
|
+
* vanished points at `rm -f $TMPDIR/so-bash-write-verify/…`.
|
|
203
|
+
*
|
|
204
|
+
* Known blind spot, and the reason the session-age signal below exists: this
|
|
205
|
+
* clock is the mtime of a file the observed actor can write. `touch
|
|
206
|
+
* .claude/wave-scope.json` (content unchanged → no control notice either)
|
|
207
|
+
* resets the age to zero and neutralises the very heuristic meant to catch the
|
|
208
|
+
* deletion it accompanies (#945).
|
|
209
|
+
*/
|
|
210
|
+
const SNAPSHOT_MISSING_SUSPICION_MS = 10 * 60 * 1000;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A missing snapshot this long after the SESSION started is suspicious (#945).
|
|
214
|
+
*
|
|
215
|
+
* The complement to the constant above, chosen because it is not the mtime of
|
|
216
|
+
* anything the scope-file write touches: `.orchestrator/current-session.json`
|
|
217
|
+
* carries the session start timestamp, so a `touch` on wave-scope.json moves it
|
|
218
|
+
* not at all. The threshold matches the scope-age one — both encode "a genuine
|
|
219
|
+
* first Bash call is minutes away from its trigger, not tens of minutes".
|
|
220
|
+
*
|
|
221
|
+
* Honest about what it is NOT: this marker is a plain file, writable by the
|
|
222
|
+
* same Bash call. It raises the cost of a silent deletion from one `rm` to a
|
|
223
|
+
* second, differently-shaped forgery; it is not tamper-proofing. And like the
|
|
224
|
+
* scope-age signal it produces exactly one false alarm after a `$TMPDIR` purge
|
|
225
|
+
* or a reboot mid-session — the same trade already accepted for #938 fix 3.
|
|
226
|
+
*/
|
|
227
|
+
const SESSION_AGE_SUSPICION_MS = 10 * 60 * 1000;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Repo-relative paths whose changes are never attributable to a scope
|
|
231
|
+
* violation. Applied ON TOP of `.gitignore` — see the header table for the
|
|
232
|
+
* class each entry belongs to and why the redundancy with `.gitignore` is
|
|
233
|
+
* deliberate.
|
|
234
|
+
*/
|
|
235
|
+
export const IGNORED_PATH_PATTERNS = Object.freeze([
|
|
236
|
+
// sibling-hook + own event writes
|
|
237
|
+
/^\.orchestrator\/(metrics|debug|eval)\//,
|
|
238
|
+
/^\.orchestrator\/(current-session\.json|host\.json|session\.lock|state\.lock|STATE\.md)$/,
|
|
239
|
+
// coordinator status files, all four harness state dirs.
|
|
240
|
+
// `wave-scope.json` is deliberately NOT here (#938 vector 1): the guard's
|
|
241
|
+
// own control file must never be exempt from reporting.
|
|
242
|
+
/^\.(claude|codex|cursor|pi)\/(STATE\.md|hooks\.json)$/,
|
|
243
|
+
/^\.(claude|codex|cursor|pi)\/(metrics|worktrees)\//,
|
|
244
|
+
/^\.(claude|codex|cursor|pi)\/[^/]*\.lock$/,
|
|
245
|
+
// package-manager artefacts
|
|
246
|
+
/^node_modules\//,
|
|
247
|
+
/(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb)$/,
|
|
248
|
+
// build / coverage output
|
|
249
|
+
/^(coverage|dist|build|\.next|\.turbo|out)\//,
|
|
250
|
+
/(^|\/)junit\.xml$/,
|
|
251
|
+
/\.log$/,
|
|
252
|
+
// tmp+rename residue (writeJsonAtomicSync, atomicMutateJson, …)
|
|
253
|
+
/\.tmp$/,
|
|
254
|
+
/\.tmp[-.][^/]*$/,
|
|
255
|
+
/(^|\/)\.tmp[-.][^/]*$/,
|
|
256
|
+
// OS noise
|
|
257
|
+
/(^|\/)(\.DS_Store|Thumbs\.db)$/,
|
|
258
|
+
// Collapsed-directory records. `--untracked-files=all` should prevent these,
|
|
259
|
+
// but a git version or config that still collapses must not turn a fresh
|
|
260
|
+
// harness state dir into a scope violation. A trailing-slash record means the
|
|
261
|
+
// WHOLE directory is untracked — for these four that is first-run harness
|
|
262
|
+
// scaffolding, never a Bash write into someone else's file scope.
|
|
263
|
+
/^\.(orchestrator|claude|codex|cursor|pi)\/$/,
|
|
264
|
+
]);
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// Pure helpers (exported for tests)
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Is this repo-relative path exempt from reporting?
|
|
272
|
+
*
|
|
273
|
+
* @param {string} relPath forward-slash repo-relative path
|
|
274
|
+
* @returns {boolean}
|
|
275
|
+
*/
|
|
276
|
+
export function isIgnoredPath(relPath) {
|
|
277
|
+
if (typeof relPath !== 'string' || relPath === '') return true;
|
|
278
|
+
return IGNORED_PATH_PATTERNS.some((re) => re.test(relPath));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Parse `git status --porcelain -z` output into repo-relative paths.
|
|
283
|
+
*
|
|
284
|
+
* The `-z` form is NUL-separated with no quoting/escaping, so a path containing
|
|
285
|
+
* a newline or a quote survives intact — the reason `-z` is not optional here.
|
|
286
|
+
* Each record is `XY<space><path>`; `--no-renames` is passed so the two-path
|
|
287
|
+
* rename record shape never occurs.
|
|
288
|
+
*
|
|
289
|
+
* @param {string} raw
|
|
290
|
+
* @returns {string[]}
|
|
291
|
+
*/
|
|
292
|
+
export function parsePorcelainZ(raw) {
|
|
293
|
+
if (typeof raw !== 'string' || raw === '') return [];
|
|
294
|
+
const out = [];
|
|
295
|
+
for (const record of raw.split('\0')) {
|
|
296
|
+
if (record.length < 4) continue; // "XY p" is the shortest possible record
|
|
297
|
+
out.push(record.slice(3));
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Is a repo-relative path covered by the wave's `allowedPaths`?
|
|
304
|
+
*
|
|
305
|
+
* Reuses `pathMatchesPattern` — the same matcher `enforce-scope.mjs` Gate 7
|
|
306
|
+
* applies — so a path this hook reports is exactly a path the Edit/Write gate
|
|
307
|
+
* would have denied. No bespoke matching.
|
|
308
|
+
*
|
|
309
|
+
* @param {string} relPath
|
|
310
|
+
* @param {string[]} allowedPaths
|
|
311
|
+
* @returns {boolean}
|
|
312
|
+
*/
|
|
313
|
+
export function isInScope(relPath, allowedPaths) {
|
|
314
|
+
if (!Array.isArray(allowedPaths) || allowedPaths.length === 0) return false;
|
|
315
|
+
return allowedPaths.some((p) => typeof p === 'string' && pathMatchesPattern(relPath, p));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Stable signature of the wave's scope. A change means a new wave, which must
|
|
320
|
+
* re-baseline silently rather than blame the next Bash call for the previous
|
|
321
|
+
* wave's dirt.
|
|
322
|
+
*
|
|
323
|
+
* @param {string[]} allowedPaths
|
|
324
|
+
* @returns {string}
|
|
325
|
+
*/
|
|
326
|
+
export function scopeSignature(allowedPaths) {
|
|
327
|
+
const list = Array.isArray(allowedPaths) ? [...allowedPaths].filter((p) => typeof p === 'string') : [];
|
|
328
|
+
list.sort();
|
|
329
|
+
return createHash('sha1').update(list.join('\n')).digest('hex').slice(0, 16);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Absolute path of the snapshot sidecar for a given repo root.
|
|
334
|
+
*
|
|
335
|
+
* Deliberately under `os.tmpdir()`, never inside the repo: an in-repo sidecar
|
|
336
|
+
* would itself appear in `git status` and the guard would report its own
|
|
337
|
+
* bookkeeping on the next call.
|
|
338
|
+
*
|
|
339
|
+
* @param {string} repoRoot
|
|
340
|
+
* @returns {string}
|
|
341
|
+
*/
|
|
342
|
+
export function snapshotPathFor(repoRoot) {
|
|
343
|
+
const key = createHash('sha1').update(String(repoRoot)).digest('hex').slice(0, 16);
|
|
344
|
+
return path.join(os.tmpdir(), 'so-bash-write-verify', `${key}.json`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Core decision: which out-of-scope paths are NEW since the last invocation?
|
|
349
|
+
*
|
|
350
|
+
* On a re-baseline (no snapshot, or a changed scope signature) the pre-#938
|
|
351
|
+
* behaviour was total silence. That let the observed actor buy blindness by
|
|
352
|
+
* deleting the `$TMPDIR` sidecar (vector 2), and made the first call after
|
|
353
|
+
* every wave rollover blind (vector 3). The mtime rule closes both: dirt
|
|
354
|
+
* whose mtime is NEWER than the wave-scope.json write happened during THIS
|
|
355
|
+
* wave and is reported even on a re-baseline; older dirt predates the wave
|
|
356
|
+
* and stays silent (pre-wave dirt is never blamed on the observed call).
|
|
357
|
+
*
|
|
358
|
+
* @param {object} args
|
|
359
|
+
* @param {string[]} args.dirtyPaths repo-relative paths from git status
|
|
360
|
+
* @param {string[]} args.allowedPaths wave allowedPaths
|
|
361
|
+
* @param {object|null} args.snapshot previous `{ signature, paths[] }`, or null
|
|
362
|
+
* @param {string} args.signature current scope signature
|
|
363
|
+
* @param {number|null} [args.scopeMtimeMs] mtime (ms) of wave-scope.json, or null
|
|
364
|
+
* @param {(relPath: string) => (number|null)} [args.mtimeMs] mtime lookup for a
|
|
365
|
+
* dirty path; null = not attributable (deleted path / stat failure)
|
|
366
|
+
* @returns {{ report: string[], nextSnapshot: { signature: string, paths: string[] }, rebaselined: boolean }}
|
|
367
|
+
*/
|
|
368
|
+
export function computeReport({ dirtyPaths, allowedPaths, snapshot, signature, scopeMtimeMs = null, mtimeMs = null, scopeRelPath = null }) {
|
|
369
|
+
const outOfScope = dirtyPaths
|
|
370
|
+
.filter((p) => !isIgnoredPath(p))
|
|
371
|
+
.filter((p) => !isInScope(p, allowedPaths))
|
|
372
|
+
// The wave-scope.json control file is out-of-scope by construction (it is
|
|
373
|
+
// never under allowedPaths) but its CHANGES are reported via the content-hash
|
|
374
|
+
// control-notice path, not here. Excluding it keeps the mtime re-baseline
|
|
375
|
+
// filter from reporting the scope file against ITSELF — its mtime always
|
|
376
|
+
// equals scopeMtimeMs, so the MED-3 `>=` boundary would otherwise flag it on
|
|
377
|
+
// every first run (#938 MED-3 follow-through).
|
|
378
|
+
.filter((p) => p !== scopeRelPath);
|
|
379
|
+
|
|
380
|
+
const rebaselined = !snapshot || snapshot.signature !== signature;
|
|
381
|
+
const seen = rebaselined || !Array.isArray(snapshot?.paths) ? new Set() : new Set(snapshot.paths);
|
|
382
|
+
|
|
383
|
+
let report;
|
|
384
|
+
if (!rebaselined) {
|
|
385
|
+
// Report-once: only paths absent from the previous snapshot.
|
|
386
|
+
report = outOfScope.filter((p) => !seen.has(p));
|
|
387
|
+
} else if (typeof scopeMtimeMs === 'number' && typeof mtimeMs === 'function') {
|
|
388
|
+
// #938 vectors 2+3: a re-baseline is only silent about PRE-WAVE dirt.
|
|
389
|
+
// MED-3 (W4 panel): `>=`, not `>`. A write whose mtime lands on the SAME
|
|
390
|
+
// coarse-FS tick as the scope file (or the same instant) is dirt from THIS
|
|
391
|
+
// wave, not pre-wave — a strict `>` silently dropped it (equal-mtime
|
|
392
|
+
// false-negative). The deeper evasion (an actor bumping the scope file's
|
|
393
|
+
// mtime ABOVE its own out-of-scope write to force a silent re-baseline)
|
|
394
|
+
// remains a warn-only detection-integrity gap — see follow-up.
|
|
395
|
+
report = outOfScope.filter((p) => {
|
|
396
|
+
const m = mtimeMs(p);
|
|
397
|
+
return typeof m === 'number' && m >= scopeMtimeMs;
|
|
398
|
+
});
|
|
399
|
+
} else {
|
|
400
|
+
// No mtime signal (scope-file stat failed / caller supplied none): degrade
|
|
401
|
+
// to the conservative silent re-baseline rather than blame pre-wave dirt.
|
|
402
|
+
report = [];
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Fold everything observed into the next snapshot, so a path reported now is
|
|
406
|
+
// never reported again for this wave.
|
|
407
|
+
const nextPaths = [...new Set([...seen, ...outOfScope])].slice(-MAX_SNAPSHOT_PATHS);
|
|
408
|
+
return { report, nextSnapshot: { signature, paths: nextPaths }, rebaselined };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Render the operator/Claude-facing message, clamped on BOTH axes (path count
|
|
413
|
+
* and total chars) so the envelope can never approach the 64 KiB pipe buffer.
|
|
414
|
+
*
|
|
415
|
+
* @param {string[]} report
|
|
416
|
+
* @param {number} allowedCount
|
|
417
|
+
* @returns {string}
|
|
418
|
+
*/
|
|
419
|
+
export function formatMessage(report, allowedCount) {
|
|
420
|
+
const shown = report.slice(0, MAX_REPORTED_PATHS);
|
|
421
|
+
const more = report.length - shown.length;
|
|
422
|
+
const tail = more > 0 ? ` (+${more} more)` : '';
|
|
423
|
+
const msg =
|
|
424
|
+
`bash-write-verify: ${report.length} file(s) changed by a Bash call OUTSIDE the wave's `
|
|
425
|
+
+ `${allowedCount} allowedPaths — ${shown.join(', ')}${tail}. `
|
|
426
|
+
+ 'Warn-only (#915): PostToolUse cannot block a command that already ran. '
|
|
427
|
+
+ 'If this was unintended, revert it; Bash writes are NOT covered by the PreToolUse path gate.';
|
|
428
|
+
return msg.length > MAX_CONTEXT_CHARS ? `${msg.slice(0, MAX_CONTEXT_CHARS - 1)}…` : msg;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Render the control-file-change notice (#938 vector 1).
|
|
433
|
+
*
|
|
434
|
+
* Fired whenever the wave-scope.json CONTENT differs from the state recorded
|
|
435
|
+
* in the snapshot. The coordinator's per-wave rollover legitimately triggers
|
|
436
|
+
* this once per wave — that case reads as information, not alarm
|
|
437
|
+
* (`weakened: false`). A change that turns enforcement off, disables the
|
|
438
|
+
* bash-write-verify gate, or makes the file unparseable is a weakening
|
|
439
|
+
* (`weakened: true`): exactly the write an actor silencing the guard must
|
|
440
|
+
* make, so it is the one line the operator must not miss.
|
|
441
|
+
*
|
|
442
|
+
* @param {string} relScopePath repo-relative path of the scope file
|
|
443
|
+
* @param {{ hash: string, enforcement: string, gateOn: boolean }} prev
|
|
444
|
+
* @param {{ hash: string, enforcement: string, gateOn: boolean }} cur
|
|
445
|
+
* @returns {{ text: string, weakened: boolean }}
|
|
446
|
+
*/
|
|
447
|
+
export function formatControlNotice(relScopePath, prev, cur) {
|
|
448
|
+
const prevGateOn = prev.gateOn !== false;
|
|
449
|
+
const curGateOn = cur.gateOn !== false;
|
|
450
|
+
const deltas = [];
|
|
451
|
+
if (prev.enforcement !== cur.enforcement) deltas.push(`enforcement ${prev.enforcement}→${cur.enforcement}`);
|
|
452
|
+
if (prevGateOn !== curGateOn) deltas.push(`gates.bash-write-verify ${prevGateOn ? 'on' : 'off'}→${curGateOn ? 'on' : 'off'}`);
|
|
453
|
+
const weakened = (cur.enforcement === 'off' && prev.enforcement !== 'off')
|
|
454
|
+
|| cur.enforcement === 'unparseable'
|
|
455
|
+
|| (prevGateOn && !curGateOn);
|
|
456
|
+
const what = deltas.length > 0 ? deltas.join(', ') : 'allowedPaths/content change';
|
|
457
|
+
const text = `bash-write-verify: control file ${relScopePath} changed since the last Bash call (${what}). `
|
|
458
|
+
+ (weakened
|
|
459
|
+
? 'This weakens or disables the bash-write guard from the NEXT call on; the current call was still evaluated under the previous scope state (#938).'
|
|
460
|
+
: 'Expected once per wave rollover; scope-control changes are never exempt from reporting (#938).');
|
|
461
|
+
return { text: text.length > MAX_CONTEXT_CHARS ? `${text.slice(0, MAX_CONTEXT_CHARS - 1)}…` : text, weakened };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Render the lost-snapshot notice (#938 fix 3, extended by #945), or null when
|
|
466
|
+
* neither clock finds the absence suspicious.
|
|
467
|
+
*
|
|
468
|
+
* Both inputs are ages in ms and both are OPTIONAL: an unknown clock
|
|
469
|
+
* contributes nothing rather than a guess. Two independent clocks are ORed
|
|
470
|
+
* because each has a different blind spot — the scope-file mtime is resettable
|
|
471
|
+
* by the observed actor (`touch`), and the session start time is unknown
|
|
472
|
+
* outside an orchestrated session. Either one alone is evadable; both together
|
|
473
|
+
* cost the actor two forgeries instead of one.
|
|
474
|
+
*
|
|
475
|
+
* Fail-safe direction is SILENCE. Neither clock readable ⇒ null. This hook is
|
|
476
|
+
* warn-only, and noise is precisely what gets a guard switched off — the #915
|
|
477
|
+
* bypass pressure it exists to fight.
|
|
478
|
+
*
|
|
479
|
+
* `reason` names WHICH untrusted state was observed. Both are the same finding —
|
|
480
|
+
* "this call had no trustworthy prior state" — and differ only in what the
|
|
481
|
+
* operator will find on disk when they look: nothing (`absent`), or a file whose
|
|
482
|
+
* contents this build did not write (`untrusted`). Keeping the substring "state
|
|
483
|
+
* snapshot" in both keeps one grep across the pair.
|
|
484
|
+
*
|
|
485
|
+
* @param {object} args
|
|
486
|
+
* @param {number|null} [args.sessionAge] ms since session start, or null
|
|
487
|
+
* @param {number|null} [args.scopeAge] ms since the wave-scope.json mtime, or null
|
|
488
|
+
* @param {'absent'|'untrusted'} [args.reason] which untrusted state was observed
|
|
489
|
+
* @returns {string|null}
|
|
490
|
+
*/
|
|
491
|
+
export function formatSnapshotMissingNotice({ sessionAge = null, scopeAge = null, reason = 'absent' } = {}) {
|
|
492
|
+
const reasons = [];
|
|
493
|
+
if (typeof sessionAge === 'number' && Number.isFinite(sessionAge) && sessionAge > SESSION_AGE_SUSPICION_MS) {
|
|
494
|
+
reasons.push(`the session started ${Math.round(sessionAge / 60_000)} min ago`);
|
|
495
|
+
}
|
|
496
|
+
if (typeof scopeAge === 'number' && Number.isFinite(scopeAge) && scopeAge > SNAPSHOT_MISSING_SUSPICION_MS) {
|
|
497
|
+
reasons.push(`the wave scope is ${Math.round(scopeAge / 60_000)} min old`);
|
|
498
|
+
}
|
|
499
|
+
if (reasons.length === 0) return null;
|
|
500
|
+
const head = reason === 'untrusted'
|
|
501
|
+
? 'a state snapshot exists but carries no scope state this build wrote'
|
|
502
|
+
: 'no state snapshot found';
|
|
503
|
+
return `bash-write-verify: ${head} although ${reasons.join(' and ')} — `
|
|
504
|
+
+ 'a deleted or overwritten state file ($TMPDIR/so-bash-write-verify) cannot be ruled out; '
|
|
505
|
+
+ 're-baselined with mtime attribution (#938/#945).';
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
// I/O helpers
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Collect the working-tree dirty set. Returns null when git is unavailable, the
|
|
514
|
+
* directory is not a repo, or the call times out — all of which mean "no signal
|
|
515
|
+
* to report", never a warning.
|
|
516
|
+
*
|
|
517
|
+
* @param {string} repoRoot
|
|
518
|
+
* @returns {string[]|null}
|
|
519
|
+
*/
|
|
520
|
+
function readDirtyPaths(repoRoot) {
|
|
521
|
+
try {
|
|
522
|
+
const raw = execFileSync(
|
|
523
|
+
'git',
|
|
524
|
+
// `--untracked-files=all` is load-bearing, not a tuning knob: the default
|
|
525
|
+
// `normal` COLLAPSES a wholly-untracked directory into a single `dir/`
|
|
526
|
+
// record, which defeats every file-level ignore pattern below (a fresh
|
|
527
|
+
// `.orchestrator/` arrives as `.orchestrator/`, not as
|
|
528
|
+
// `.orchestrator/metrics/events.jsonl`). Measured on this repo it is also
|
|
529
|
+
// not slower — 25.0 ms/call vs 28.7 ms for `normal` — because the
|
|
530
|
+
// expensive subtrees (node_modules/, coverage/) are .gitignore'd and git
|
|
531
|
+
// never descends into them.
|
|
532
|
+
['--no-optional-locks', 'status', '--porcelain', '-z', '--no-renames', '--untracked-files=all'],
|
|
533
|
+
{ cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
534
|
+
);
|
|
535
|
+
return parsePorcelainZ(raw);
|
|
536
|
+
} catch {
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Age in ms of one session clock: a JSON file carrying an ISO start timestamp.
|
|
543
|
+
*
|
|
544
|
+
* Never throws. Absent / unparseable / non-string / non-ISO ⇒ null. A NEGATIVE
|
|
545
|
+
* age (timestamp in the future) is also null rather than a negative number —
|
|
546
|
+
* see `sessionAgeMs` for why that matters once two clocks are combined.
|
|
547
|
+
*
|
|
548
|
+
* @param {string} file absolute path to the JSON file
|
|
549
|
+
* @param {string} field name of the ISO-timestamp property
|
|
550
|
+
* @param {number} now
|
|
551
|
+
* @returns {number|null}
|
|
552
|
+
*/
|
|
553
|
+
function clockAgeMs(file, field, now) {
|
|
554
|
+
try {
|
|
555
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
556
|
+
const startedAt = Date.parse(parsed?.[field]);
|
|
557
|
+
if (!Number.isFinite(startedAt)) return null;
|
|
558
|
+
const age = now - startedAt;
|
|
559
|
+
return age >= 0 ? age : null;
|
|
560
|
+
} catch {
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Milliseconds since the current session started, or null when unknown — the
|
|
567
|
+
* MINIMUM of two independently-written session clocks (#957 finding 2):
|
|
568
|
+
*
|
|
569
|
+
* - `.orchestrator/current-session.json` `timestamp`, written once per
|
|
570
|
+
* session by `hooks/on-session-start.mjs`;
|
|
571
|
+
* - `.orchestrator/session.lock` `started_at`, written by session-start
|
|
572
|
+
* Phase 1.2 via `scripts/lib/session-lock.mjs` `acquire()`.
|
|
573
|
+
*
|
|
574
|
+
* Both share the two properties that make them the right clocks here: each is
|
|
575
|
+
* `.gitignore`d AND on this hook's own ignore list (see IGNORED_PATH_PATTERNS),
|
|
576
|
+
* so reading one can never turn into reporting it; and neither is touched by
|
|
577
|
+
* any write to wave-scope.json, which is exactly the evasion the scope-mtime
|
|
578
|
+
* clock lost to (#945).
|
|
579
|
+
*
|
|
580
|
+
* ## Why the minimum, and why NOT a staleness cap
|
|
581
|
+
*
|
|
582
|
+
* A single clock reads an age of HOURS whenever its file is a leftover from a
|
|
583
|
+
* previous session on a harness that never rewrote it — firing the lost-snapshot
|
|
584
|
+
* notice on the first Bash call of a session seconds old. The obvious repair, a
|
|
585
|
+
* hard "ignore ages above N hours" cap, is the wrong one: it blinds the guard
|
|
586
|
+
* in precisely the regime it was built for (its own docblock names "a mid-session
|
|
587
|
+
* `rm -f` sits hours after session start"), so a legitimate long deep session
|
|
588
|
+
* ages into the blind spot with no attacker action at all — and it converts a
|
|
589
|
+
* one-sided threshold into a two-sided one, giving a forged timestamp two
|
|
590
|
+
* winning ranges instead of one.
|
|
591
|
+
*
|
|
592
|
+
* `Math.min` fixes the leftover-file case: a session that actually started
|
|
593
|
+
* acquired a FRESH `session.lock`, so the stale clock is outvoted by the young
|
|
594
|
+
* one ⇒ silence. After 14 h of a genuine deep session both clocks read 14 h ⇒
|
|
595
|
+
* the notice fires.
|
|
596
|
+
*
|
|
597
|
+
* ## What min() actually costs an attacker (#957/F2 — corrected)
|
|
598
|
+
*
|
|
599
|
+
* The notice fires only ABOVE the threshold (`sessionAge >
|
|
600
|
+
* SESSION_AGE_SUSPICION_MS`), so silence is bought by a YOUNG value, not an old
|
|
601
|
+
* one. min() therefore narrows the FIRING set (it now takes BOTH clocks reading
|
|
602
|
+
* old) and correspondingly WIDENS the forgeable target set: a young value in
|
|
603
|
+
* EITHER file suffices. The price is still one forgery — but the attacker now
|
|
604
|
+
* chooses which of two files to write, where before there was one. (`max()` is
|
|
605
|
+
* the operator that would cost two forgeries; it was not chosen, because it
|
|
606
|
+
* re-admits the leftover-file false positive this change exists to remove. An
|
|
607
|
+
* earlier draft of this docblock asserted the min() variant cost two — it does
|
|
608
|
+
* not.) Both files are `.gitignore`d and unwritable-by-convention rather than
|
|
609
|
+
* by permission, so this is a real widening, judged an acceptable trade against
|
|
610
|
+
* a guard that otherwise cried wolf on the first Bash call of every session.
|
|
611
|
+
*
|
|
612
|
+
* KNOWN SILENCE RANGE (non-adversarial): `acquire()` writes a fresh `started_at`
|
|
613
|
+
* only when no lock is present — it classifies and returns when one exists
|
|
614
|
+
* (`scripts/lib/session-lock.mjs`), while `forceAcquire()` overwrites
|
|
615
|
+
* unconditionally. A session that released and re-acquired, or took over a stale
|
|
616
|
+
* lock, therefore carries a YOUNG `started_at` against an OLD
|
|
617
|
+
* `current-session.json`; min() picks the young one and the sessionAge clock
|
|
618
|
+
* stays blind for the rest of that session however long it runs. Contrary to an
|
|
619
|
+
* earlier claim here, this change DOES create a new silence range — just not an
|
|
620
|
+
* adversarial one.
|
|
621
|
+
*
|
|
622
|
+
* If that range proves load-bearing, the follow-up worth considering is
|
|
623
|
+
* per-clock evaluation (fire when ANY clock reads old AND is corroborated)
|
|
624
|
+
* rather than a single combined age. That is a behaviour change, not a
|
|
625
|
+
* documentation one, and is deliberately NOT made here.
|
|
626
|
+
*
|
|
627
|
+
* Negative ages are dropped rather than kept (see `clockAgeMs`): without that,
|
|
628
|
+
* a single timestamp in the FUTURE would win the min() and silence the notice
|
|
629
|
+
* unboundedly — a strictly worse range than the two above.
|
|
630
|
+
*
|
|
631
|
+
* Never throws. No clock readable ⇒ null ⇒ silence.
|
|
632
|
+
*
|
|
633
|
+
* @param {string} repoRoot
|
|
634
|
+
* @param {number} [now]
|
|
635
|
+
* @returns {number|null}
|
|
636
|
+
*/
|
|
637
|
+
export function sessionAgeMs(repoRoot, now = Date.now()) {
|
|
638
|
+
const dir = path.join(repoRoot, '.orchestrator');
|
|
639
|
+
const ages = [
|
|
640
|
+
clockAgeMs(path.join(dir, 'current-session.json'), 'timestamp', now),
|
|
641
|
+
clockAgeMs(path.join(dir, 'session.lock'), 'started_at', now),
|
|
642
|
+
].filter((age) => age !== null);
|
|
643
|
+
return ages.length > 0 ? Math.min(...ages) : null;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** @returns {object|null} */
|
|
647
|
+
function readSnapshot(file) {
|
|
648
|
+
try {
|
|
649
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
650
|
+
} catch {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* The recorded scope state, but ONLY when the whole record is one this build
|
|
657
|
+
* wrote and can act on. Anything else ⇒ null ⇒ the caller evaluates strict.
|
|
658
|
+
*
|
|
659
|
+
* This is the single trust decision of the hook, and it is deliberately about
|
|
660
|
+
* CONTENT, never about the sidecar's existence. Keying it on `snapshot === null`
|
|
661
|
+
* (the #944 shape) closed one state of four at unchanged attacker cost — `rm -f
|
|
662
|
+
* <path>` became `echo '{}' > <path>`, same computable path, same Bash round —
|
|
663
|
+
* and left `{}` and `{"scopeState":{"hash":"…"}}` completely silent. Each clause
|
|
664
|
+
* below therefore names a forgery it refuses:
|
|
665
|
+
*
|
|
666
|
+
* schema !== SNAPSHOT_SCHEMA `{}` / any older or hand-written shape
|
|
667
|
+
* scopeState not an object `{"schema":2}`
|
|
668
|
+
* enforcement not a string `{"schema":2,"scopeState":{"hash":"deadbeef"}}`
|
|
669
|
+
* hash not a string a half-written record the control-notice path
|
|
670
|
+
* would otherwise compare against
|
|
671
|
+
*
|
|
672
|
+
* Residual, named rather than implied: a forgery that supplies a COMPLETE record
|
|
673
|
+
* (marker, hash, `enforcement: "off"`) is still believed. That costs the actor
|
|
674
|
+
* the one thing the cheap forgeries bought silence to avoid — writing the
|
|
675
|
+
* disabling value where the next call reads it as a prior state and reports the
|
|
676
|
+
* transition. The gap this closes is the free one.
|
|
677
|
+
*
|
|
678
|
+
* @param {object|null} snapshot
|
|
679
|
+
* @returns {{ hash: string, enforcement: string, gateOn?: boolean }|null}
|
|
680
|
+
*/
|
|
681
|
+
function trustedScopeState(snapshot) {
|
|
682
|
+
if (!snapshot || typeof snapshot !== 'object') return null;
|
|
683
|
+
if (snapshot.schema !== SNAPSHOT_SCHEMA) return null;
|
|
684
|
+
const state = snapshot.scopeState;
|
|
685
|
+
if (!state || typeof state !== 'object') return null;
|
|
686
|
+
if (typeof state.enforcement !== 'string') return null;
|
|
687
|
+
if (typeof state.hash !== 'string') return null;
|
|
688
|
+
return state;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Atomic tmp+rename write; failure is non-fatal (worst case: a re-baseline).
|
|
693
|
+
*
|
|
694
|
+
* The schema marker is stamped HERE, not at the call sites: it is the property
|
|
695
|
+
* that makes a record trustworthy, so the one place that produces records is the
|
|
696
|
+
* one place that may claim it.
|
|
697
|
+
*/
|
|
698
|
+
function writeSnapshot(file, data) {
|
|
699
|
+
try {
|
|
700
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
701
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
702
|
+
writeFileSync(tmp, JSON.stringify({ ...data, schema: SNAPSHOT_SCHEMA }), 'utf8');
|
|
703
|
+
renameSync(tmp, file);
|
|
704
|
+
} catch {
|
|
705
|
+
/* best-effort */
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ---------------------------------------------------------------------------
|
|
710
|
+
// Main
|
|
711
|
+
// ---------------------------------------------------------------------------
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Emit stderr line(s) for the operator + one PostToolUse envelope for Claude.
|
|
715
|
+
* `warn` selects the stderr glyph: violations and control-file weakenings
|
|
716
|
+
* alarm (⚠); a plain rollover/teardown notice informs (ℹ).
|
|
717
|
+
*
|
|
718
|
+
* @param {string[]} messages
|
|
719
|
+
* @param {boolean} warn
|
|
720
|
+
*/
|
|
721
|
+
function emitMessages(messages, warn) {
|
|
722
|
+
const glyph = warn ? '⚠' : 'ℹ';
|
|
723
|
+
try {
|
|
724
|
+
for (const m of messages) process.stderr.write(`${glyph} ${m}\n`);
|
|
725
|
+
} catch {
|
|
726
|
+
/* stderr may be closed */
|
|
727
|
+
}
|
|
728
|
+
writeStdoutLineSync(JSON.stringify({
|
|
729
|
+
hookSpecificOutput: {
|
|
730
|
+
hookEventName: 'PostToolUse',
|
|
731
|
+
additionalContext: messages.join('\n'),
|
|
732
|
+
},
|
|
733
|
+
}));
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function main() {
|
|
737
|
+
const input = await readStdin();
|
|
738
|
+
if (!input) return;
|
|
739
|
+
|
|
740
|
+
// G2 — only Bash calls carry the bypass risk this hook watches.
|
|
741
|
+
if (input.tool_name !== 'Bash') return;
|
|
742
|
+
|
|
743
|
+
const repoRootRaw = resolveProjectDir();
|
|
744
|
+
let repoRoot;
|
|
745
|
+
try {
|
|
746
|
+
repoRoot = realpathSync(repoRootRaw);
|
|
747
|
+
} catch {
|
|
748
|
+
repoRoot = repoRootRaw;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const snapFile = snapshotPathFor(repoRoot);
|
|
752
|
+
const snapshot = readSnapshot(snapFile);
|
|
753
|
+
// The ONE trust decision (see `trustedScopeState`). Every downstream use of a
|
|
754
|
+
// "previous" value reads this binding, so no code path can accidentally act on
|
|
755
|
+
// a recorded value that failed the check.
|
|
756
|
+
const prevScopeState = trustedScopeState(snapshot);
|
|
757
|
+
// The rest of an untrusted record is untrusted too. `signature` + `paths` drive
|
|
758
|
+
// computeReport's report-once suppression, so a forged
|
|
759
|
+
// `{"signature":"<sig>","paths":["out-of-scope.mjs"]}` would buy silence on the
|
|
760
|
+
// path axis by the same one-line write — the report-once list is a claim about
|
|
761
|
+
// what was ALREADY reported, and this build reported nothing. Untrusted ⇒ the
|
|
762
|
+
// same empty base the absent-sidecar case starts from.
|
|
763
|
+
const prevRecord = prevScopeState === null ? null : snapshot;
|
|
764
|
+
const carriedRecord = prevRecord ?? { signature: null, paths: [] };
|
|
765
|
+
|
|
766
|
+
// G3 — no wave scope → nothing defines "outside". #938: if a previous call
|
|
767
|
+
// RECORDED a scope state, the control file's disappearance is itself a
|
|
768
|
+
// control-file change and gets one visible (non-alarming) notice.
|
|
769
|
+
const scopePath = findScopeFile(repoRoot);
|
|
770
|
+
if (!scopePath) {
|
|
771
|
+
if (prevScopeState && prevScopeState.hash !== 'absent') {
|
|
772
|
+
writeSnapshot(snapFile, {
|
|
773
|
+
...carriedRecord,
|
|
774
|
+
scopeState: { hash: 'absent', enforcement: 'strict', gateOn: true },
|
|
775
|
+
});
|
|
776
|
+
emitMessages([
|
|
777
|
+
'bash-write-verify: control file wave-scope.json was REMOVED since the last Bash call — '
|
|
778
|
+
+ 'the bash-write guard is inert until a new wave scope is written. '
|
|
779
|
+
+ 'Expected at session teardown; scope-control changes are never exempt from reporting (#938).',
|
|
780
|
+
], false);
|
|
781
|
+
}
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
let raw = null;
|
|
786
|
+
let scope = null;
|
|
787
|
+
try {
|
|
788
|
+
raw = readFileSync(scopePath, 'utf8');
|
|
789
|
+
scope = JSON.parse(raw);
|
|
790
|
+
} catch {
|
|
791
|
+
/* raw stays null (unreadable) or scope stays null (unparseable) */
|
|
792
|
+
}
|
|
793
|
+
if (raw === null) return; // file vanished between findScopeFile and read
|
|
794
|
+
|
|
795
|
+
const relScopePath = path.relative(repoRoot, scopePath) || scopePath;
|
|
796
|
+
const currentScopeState = {
|
|
797
|
+
hash: createHash('sha1').update(raw).digest('hex').slice(0, 16),
|
|
798
|
+
enforcement: scope
|
|
799
|
+
? (typeof scope.enforcement === 'string' ? scope.enforcement : 'strict')
|
|
800
|
+
: 'unparseable',
|
|
801
|
+
gateOn: scope ? scope?.gates?.['bash-write-verify'] !== false : true,
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
let scopeMtimeMs = null;
|
|
805
|
+
try {
|
|
806
|
+
scopeMtimeMs = statSync(scopePath).mtimeMs;
|
|
807
|
+
} catch {
|
|
808
|
+
/* stat failure → computeReport degrades to the silent re-baseline */
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// The lost-snapshot signal is computed HERE — ahead of every early return
|
|
812
|
+
// below (#944/#945). It used to sit at the very end of the reporting path,
|
|
813
|
+
// which made it worthless against the attack it was built for: `enforcement:
|
|
814
|
+
// "off"` returns at G5, so a single Bash call that deleted the sidecar AND
|
|
815
|
+
// disabled the gate skipped the suspicion line as well as the path report.
|
|
816
|
+
// A guard whose "I may have been tampered with" notice is itself behind the
|
|
817
|
+
// gate the tampering opens is not a guard.
|
|
818
|
+
//
|
|
819
|
+
// Every return point reachable from here writes a snapshot, which is what
|
|
820
|
+
// keeps this to ONE line: the next call sees a snapshot and the condition is
|
|
821
|
+
// false. Without that, a genuinely purged $TMPDIR would warn on every Bash
|
|
822
|
+
// call for the rest of the session.
|
|
823
|
+
//
|
|
824
|
+
// The condition is `prevScopeState === null` — no TRUSTWORTHY prior state —
|
|
825
|
+
// not `snapshot === null`. The two effects of that condition (evaluating the
|
|
826
|
+
// call strict, and saying so) must ride the SAME predicate: keyed on file
|
|
827
|
+
// existence, this line stayed silent for `echo '{}' > <sidecar>` while the
|
|
828
|
+
// path report was restored, which drops the tamper trail and leaves a green
|
|
829
|
+
// `toContain('out-of-scope.mjs')` test to say otherwise.
|
|
830
|
+
const missingSnapshotNotice = prevScopeState === null
|
|
831
|
+
? formatSnapshotMissingNotice({
|
|
832
|
+
sessionAge: sessionAgeMs(repoRoot),
|
|
833
|
+
scopeAge: typeof scopeMtimeMs === 'number' ? Date.now() - scopeMtimeMs : null,
|
|
834
|
+
reason: snapshot === null ? 'absent' : 'untrusted',
|
|
835
|
+
})
|
|
836
|
+
: null;
|
|
837
|
+
|
|
838
|
+
// Unparseable control file: nothing to evaluate paths against, but the
|
|
839
|
+
// CHANGE is still reported (#938 vector 1 variant — `echo garbage >
|
|
840
|
+
// wave-scope.json` must not be a free total silencer).
|
|
841
|
+
if (!scope) {
|
|
842
|
+
writeSnapshot(snapFile, { ...carriedRecord, scopeState: currentScopeState });
|
|
843
|
+
const messages = [];
|
|
844
|
+
if (prevScopeState && prevScopeState.hash !== currentScopeState.hash) {
|
|
845
|
+
messages.push(formatControlNotice(relScopePath, prevScopeState, currentScopeState).text);
|
|
846
|
+
}
|
|
847
|
+
// `echo garbage > wave-scope.json` + `rm -f <sidecar>` in one call left
|
|
848
|
+
// prevScopeState null and produced total silence before #945.
|
|
849
|
+
if (missingSnapshotNotice) messages.push(missingSnapshotNotice);
|
|
850
|
+
if (messages.length > 0) emitMessages(messages, true);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// G4+G5 — evaluated on the PRE-CALL scope state from the snapshot (#938
|
|
855
|
+
// vector 1); the live values only take effect from the NEXT call on. G4 is ON
|
|
856
|
+
// unless explicitly disabled — the contrast with `bash-write-guard`
|
|
857
|
+
// (=== true) stands: this detector observes the filesystem rather than
|
|
858
|
+
// parsing a command. G5: enforcement:off means nothing is enforced, including
|
|
859
|
+
// advisories.
|
|
860
|
+
//
|
|
861
|
+
// #944/#945 — NO TRUSTWORTHY PRIOR STATE is evaluated as `strict`, never from
|
|
862
|
+
// the live file. There is no live-value fallback left on this path at all,
|
|
863
|
+
// which is the whole repair: the #944 shape kept one (`snapshot !== null` but
|
|
864
|
+
// no usable `scopeState` ⇒ read `currentScopeState`) as an anti-noise carve-out
|
|
865
|
+
// for sidecars from an older build, and that carve-out was keyed on the
|
|
866
|
+
// ABSENCE of a field — free to reproduce with `echo '{}' > <sidecar>`. The
|
|
867
|
+
// carve-out's purpose survives, moved onto `SNAPSHOT_SCHEMA`: an old build's
|
|
868
|
+
// record fails the marker check and is judged strict for ONE call, after which
|
|
869
|
+
// this build's write makes it trustworthy again.
|
|
870
|
+
//
|
|
871
|
+
// Cost, unchanged from #944 and now also paid once per deploy: a repo that
|
|
872
|
+
// legitimately runs `enforcement: "off"` may get ONE advisory line on its
|
|
873
|
+
// first Bash call, then silence from call 2 on (the snapshot now records
|
|
874
|
+
// `off`). One line is the cheaper half of the trade against a guard that a
|
|
875
|
+
// single `echo` can switch off in silence.
|
|
876
|
+
const effEnforcement = prevScopeState ? prevScopeState.enforcement : 'strict';
|
|
877
|
+
const effGateOn = prevScopeState ? prevScopeState.gateOn !== false : true;
|
|
878
|
+
if (!effGateOn || effEnforcement === 'off') {
|
|
879
|
+
// Refresh only the recorded scope state so a later re-enable is honored on
|
|
880
|
+
// the next call; signature/paths stay untouched.
|
|
881
|
+
writeSnapshot(snapFile, { ...carriedRecord, scopeState: currentScopeState });
|
|
882
|
+
// Unreachable while the strict branch above holds (a null prevScopeState
|
|
883
|
+
// never returns here). Kept as the structural guarantee rather than a
|
|
884
|
+
// comment: if a future edit reintroduces any live-value path, the tamper
|
|
885
|
+
// notice still escapes ahead of the gate instead of silently going with it.
|
|
886
|
+
if (missingSnapshotNotice) emitMessages([missingSnapshotNotice], false);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const controlNotice = prevScopeState
|
|
891
|
+
&& prevScopeState.hash !== 'absent'
|
|
892
|
+
&& prevScopeState.hash !== currentScopeState.hash
|
|
893
|
+
? formatControlNotice(relScopePath, prevScopeState, currentScopeState)
|
|
894
|
+
: null;
|
|
895
|
+
|
|
896
|
+
const allowedPaths = Array.isArray(scope.allowedPaths) ? scope.allowedPaths : [];
|
|
897
|
+
|
|
898
|
+
const dirtyPaths = readDirtyPaths(repoRoot);
|
|
899
|
+
if (dirtyPaths === null) {
|
|
900
|
+
// git unavailable → no path signal; the control-file and lost-snapshot
|
|
901
|
+
// notices do not depend on git and still stand.
|
|
902
|
+
writeSnapshot(snapFile, { ...carriedRecord, scopeState: currentScopeState });
|
|
903
|
+
const messages = [];
|
|
904
|
+
if (controlNotice) messages.push(controlNotice.text);
|
|
905
|
+
if (missingSnapshotNotice) messages.push(missingSnapshotNotice);
|
|
906
|
+
if (messages.length > 0) emitMessages(messages, Boolean(controlNotice?.weakened));
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const signature = scopeSignature(allowedPaths);
|
|
911
|
+
|
|
912
|
+
const { report, nextSnapshot } = computeReport({
|
|
913
|
+
dirtyPaths,
|
|
914
|
+
allowedPaths,
|
|
915
|
+
// `prevRecord`, not `snapshot`: an untrusted record's report-once list is a
|
|
916
|
+
// forgeable claim about what was already reported (see the binding above).
|
|
917
|
+
snapshot: prevRecord,
|
|
918
|
+
signature,
|
|
919
|
+
scopeMtimeMs,
|
|
920
|
+
scopeRelPath: relScopePath,
|
|
921
|
+
mtimeMs: (rel) => {
|
|
922
|
+
try {
|
|
923
|
+
return statSync(path.join(repoRoot, rel)).mtimeMs;
|
|
924
|
+
} catch {
|
|
925
|
+
return null; // deleted path → not mtime-attributable on a re-baseline
|
|
926
|
+
}
|
|
927
|
+
},
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
writeSnapshot(snapFile, { ...nextSnapshot, scopeState: currentScopeState });
|
|
931
|
+
|
|
932
|
+
const messages = [];
|
|
933
|
+
let warn = false;
|
|
934
|
+
if (controlNotice) {
|
|
935
|
+
messages.push(controlNotice.text);
|
|
936
|
+
warn = warn || controlNotice.weakened;
|
|
937
|
+
}
|
|
938
|
+
// #938 fix 3 + #945 — a missing sidecar that neither clock can call a clean
|
|
939
|
+
// first run is named, not silently trusted. Computed before the G4/G5 gate
|
|
940
|
+
// above so the gate cannot swallow it.
|
|
941
|
+
if (missingSnapshotNotice) messages.push(missingSnapshotNotice);
|
|
942
|
+
if (report.length > 0) {
|
|
943
|
+
messages.push(formatMessage(report, allowedPaths.length));
|
|
944
|
+
warn = true;
|
|
945
|
+
}
|
|
946
|
+
if (messages.length === 0) return; // silence is the common case (0.91 % fire rate)
|
|
947
|
+
|
|
948
|
+
emitMessages(messages, warn);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Self-execution guard — run only when invoked directly as a hook, never when
|
|
952
|
+
// imported (the test suite imports named exports; a top-level main() then runs
|
|
953
|
+
// on import and its .finally(process.exit(0)) trips vitest's process.exit guard,
|
|
954
|
+
// surfacing as an unhandled rejection that can cause false-positive tests).
|
|
955
|
+
//
|
|
956
|
+
// #938 MED-2 (W4 panel): compare the REAL path of BOTH sides. `process.argv[1]`
|
|
957
|
+
// carries the path as passed (symlink-bearing under a symlinked plugin install),
|
|
958
|
+
// while `import.meta.url` is realpath-resolved by Node's default loader — a bare
|
|
959
|
+
// string compare then reads false and silently no-ops the whole scope-detector
|
|
960
|
+
// under a symlinked `$CLAUDE_PLUGIN_ROOT`. realpath'ing both sides also survives
|
|
961
|
+
// `--preserve-symlinks` (where import.meta.url stays symlinked instead).
|
|
962
|
+
function invokedAsScript() {
|
|
963
|
+
const entry = process.argv[1];
|
|
964
|
+
if (!entry) return false;
|
|
965
|
+
const self = fileURLToPath(import.meta.url);
|
|
966
|
+
try {
|
|
967
|
+
return realpathSync(entry) === realpathSync(self);
|
|
968
|
+
} catch {
|
|
969
|
+
// argv[1] unresolvable (deleted/renamed mid-run) — best-effort raw compare.
|
|
970
|
+
return entry === self;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
const isMain = invokedAsScript();
|
|
974
|
+
if (isMain) {
|
|
975
|
+
// Advisory hook: never block, never surface an error to the tool call.
|
|
976
|
+
main().catch(() => {}).finally(() => process.exit(0));
|
|
977
|
+
}
|