session-orchestrator 3.17.0 → 3.20.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 +185 -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/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +9 -4
- 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 +794 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +245 -50
- package/docs/session-config-template.md +39 -26
- package/hooks/_lib/guard-source-loader.mjs +680 -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 +226 -19
- package/hooks/enforce-scope.mjs +133 -9
- 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 +30 -4
- 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 +616 -164
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +6 -3
- 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/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/emit-session.mjs +3 -40
- 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 +1273 -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/hardening.mjs +9 -9
- 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/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/learnings/select.mjs +550 -0
- 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 +128 -24
- package/scripts/lib/reconcile/engine.mjs +156 -54
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- 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 +568 -145
- package/scripts/lib/session-close-backfill.mjs +63 -8
- 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-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- 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/soul-resolve.mjs +12 -0
- 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/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +244 -36
- 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/check-unwired-features.mjs +549 -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 +218 -16
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/release.mjs +534 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- 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/brainstorm/soul.md +47 -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 +117 -18
- package/skills/gitlab-ops/SKILL.md +25 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/grill/soul.md +44 -1
- 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/plan/soul.md +46 -3
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -36
- 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 +32 -2
- package/skills/session-end/plan-verification.md +6 -7
- package/skills/session-end/session-metrics-write.md +2 -0
- 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 +75 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/session-start/soul.md +41 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +6 -7
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +198 -80
- package/templates/_shared/loop.md +4 -4
|
@@ -187,6 +187,27 @@ export async function bootstrapLock({
|
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
// Step 2b (#987 Part 1): persist the durable ownership proof at lock
|
|
191
|
+
// genesis. `enriched` is byte-identical to the on-disk lock at this point
|
|
192
|
+
// (the v2 overlay never touches pid/host/started_at), so the proof written
|
|
193
|
+
// here will verify via isLockOwnedByProof() against any later re-read.
|
|
194
|
+
// This single call covers BOTH the plain-acquire and the forceAcquire
|
|
195
|
+
// branch — both flow through the enriched write above. Best-effort like
|
|
196
|
+
// the surrounding breadcrumb writes: writeOwnerProof() is no-throw by
|
|
197
|
+
// contract and a failure never bails the bootstrap — but it is no longer
|
|
198
|
+
// SILENT (#987 Part 2 review finding): a failed proof write means this
|
|
199
|
+
// session's /close will degrade to the weaker proof-less release path, so
|
|
200
|
+
// a one-line stderr WARN gives the operator the only signal there is.
|
|
201
|
+
try {
|
|
202
|
+
const { writeOwnerProof } = await import('../../scripts/lib/session-lock.mjs');
|
|
203
|
+
const proofResult = writeOwnerProof({ repoRoot, lock: enriched });
|
|
204
|
+
if (proofResult && !proofResult.ok) {
|
|
205
|
+
process.stderr.write(
|
|
206
|
+
`⚠ lock-bootstrap: owner-proof write failed (${proofResult.reason ?? 'unknown'}) — /close degrades to proof-less release behaviour\n`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
} catch { /* best-effort — a missing proof never breaks session-start */ }
|
|
210
|
+
|
|
190
211
|
// Step 3: best-effort observability breadcrumb. Failures are swallowed
|
|
191
212
|
// so a missing events module never breaks the hook.
|
|
192
213
|
try {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vcs-create-matcher.mjs — shared `gh` / `glab` create-command matcher for
|
|
3
|
+
* PreToolUse Bash hooks.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `hooks/pre-bash-templates-first.mjs` (#519) so the
|
|
6
|
+
* issue-budget hook (`hooks/pre-bash-issue-budget.mjs`) reuses the SAME
|
|
7
|
+
* regex + argument-boundary semantics instead of maintaining a second,
|
|
8
|
+
* silently-diverging copy. Both hooks share the PreToolUse Bash matcher and
|
|
9
|
+
* run sequentially, so a divergence here would mean one hook gates a command
|
|
10
|
+
* the other waves through.
|
|
11
|
+
*
|
|
12
|
+
* ZERO IMPORTS by design — a clean leaf so any hook can adopt it without
|
|
13
|
+
* pulling the scripts/lib layer in.
|
|
14
|
+
*
|
|
15
|
+
* LOAD-BEARING semantics (contract, mirrors the pre-#-extraction behaviour):
|
|
16
|
+
* MATCH: `gh|glab` + `pr|mr|issue` + `create|new`, anchored at start with
|
|
17
|
+
* optional leading whitespace, word-boundary on the trailing edge
|
|
18
|
+
* (so `created` / `news` do NOT match).
|
|
19
|
+
* NO MATCH: edit operations (`gh pr edit`, `glab mr edit`) — deliberately
|
|
20
|
+
* out of scope per the #519 PRD § 2 Out-of-Scope.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Matches the canonical `gh` / `glab` issue/PR/MR creation invocations.
|
|
25
|
+
* Anchored at start (^) with optional leading whitespace to catch indented
|
|
26
|
+
* shell snippets. Word-boundary at the end avoids false positives on tokens
|
|
27
|
+
* like `created` or `news`.
|
|
28
|
+
*/
|
|
29
|
+
export const CREATE_REGEX = /^\s*(gh|glab)\s+(pr|mr|issue)\s+(create|new)\b/;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse a shell command into its VCS-create shape.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} command
|
|
35
|
+
* @returns {{ host: 'github'|'gitlab', kind: 'pr'|'mr'|'issue', verb: 'create'|'new' } | null}
|
|
36
|
+
* `null` when the command is not a `gh`/`glab` create/new invocation.
|
|
37
|
+
*/
|
|
38
|
+
export function matchVcsCreate(command) {
|
|
39
|
+
if (typeof command !== 'string' || command.length === 0) return null;
|
|
40
|
+
const m = command.match(CREATE_REGEX);
|
|
41
|
+
if (!m) return null;
|
|
42
|
+
return {
|
|
43
|
+
host: m[1] === 'gh' ? 'github' : 'gitlab',
|
|
44
|
+
kind: /** @type {'pr'|'mr'|'issue'} */ (m[2]),
|
|
45
|
+
verb: /** @type {'create'|'new'} */ (m[3]),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Determine which host the command targets. `gh` → "github", `glab` → "gitlab".
|
|
51
|
+
* Thin wrapper kept for call-site readability in pre-bash-templates-first.mjs.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} command
|
|
54
|
+
* @returns {"github"|"gitlab"|null}
|
|
55
|
+
*/
|
|
56
|
+
export function resolveHost(command) {
|
|
57
|
+
return matchVcsCreate(command)?.host ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* True when the command creates an ISSUE specifically (not a PR/MR).
|
|
62
|
+
* The issue-budget cap counts issues only — PR/MR creation is not the
|
|
63
|
+
* runaway-volume problem the cap exists to bound.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} command
|
|
66
|
+
* @returns {boolean}
|
|
67
|
+
*/
|
|
68
|
+
export function isIssueCreate(command) {
|
|
69
|
+
return matchVcsCreate(command)?.kind === 'issue';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* True when the command starts with any of the bypass patterns. Bypass match
|
|
74
|
+
* is a prefix check with a word/EOL boundary on the trailing edge — this
|
|
75
|
+
* prevents trivial bypass via prefix-inclusion (e.g. a policy entry
|
|
76
|
+
* "gh issue create --label bot" must not match "gh issue create --label botanical").
|
|
77
|
+
*
|
|
78
|
+
* @param {string} command
|
|
79
|
+
* @param {string[]} bypassPatterns
|
|
80
|
+
* @returns {boolean}
|
|
81
|
+
*/
|
|
82
|
+
export function matchesBypass(command, bypassPatterns) {
|
|
83
|
+
if (typeof command !== 'string') return false;
|
|
84
|
+
if (!Array.isArray(bypassPatterns) || bypassPatterns.length === 0) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
const stripped = command.replace(/^\s+/, '');
|
|
88
|
+
for (const pat of bypassPatterns) {
|
|
89
|
+
if (typeof pat !== 'string' || pat.length === 0) continue;
|
|
90
|
+
const patStripped = pat.replace(/^\s+/, '');
|
|
91
|
+
if (!stripped.startsWith(patStripped)) continue;
|
|
92
|
+
// Boundary check: next character must be whitespace, EOL, or absent.
|
|
93
|
+
const nextChar = stripped.charAt(patStripped.length);
|
|
94
|
+
if (nextChar === '' || /\s/.test(nextChar)) return true;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Best-effort extraction of the `--title` value from a create command, for
|
|
101
|
+
* human-readable overflow bookkeeping. Handles `--title "x"`, `--title 'x'`,
|
|
102
|
+
* `--title=x` and the bare unquoted form. Returns `null` when no title flag
|
|
103
|
+
* is present.
|
|
104
|
+
*
|
|
105
|
+
* Deliberately NOT a shell parser: the value is only ever used as a display
|
|
106
|
+
* label in an overflow record, never re-executed.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} command
|
|
109
|
+
* @returns {string|null}
|
|
110
|
+
*/
|
|
111
|
+
export function extractTitle(command) {
|
|
112
|
+
if (typeof command !== 'string' || command.length === 0) return null;
|
|
113
|
+
const m =
|
|
114
|
+
command.match(/--title[=\s]+"((?:[^"\\]|\\.)*)"/) ??
|
|
115
|
+
command.match(/--title[=\s]+'([^']*)'/) ??
|
|
116
|
+
command.match(/--title[=\s]+(\S+)/);
|
|
117
|
+
if (!m) return null;
|
|
118
|
+
return m[1].replace(/\\(["\\])/g, '$1').trim() || null;
|
|
119
|
+
}
|
|
Binary file
|
|
@@ -13,10 +13,18 @@
|
|
|
13
13
|
* G5 enforcement != "off"
|
|
14
14
|
* G6 blocked pattern match against .blockedCommands[], or
|
|
15
15
|
* fallback safety list when .blockedCommands is empty
|
|
16
|
-
* G7 strict → deny
|
|
16
|
+
* G7 strict → deny; warn → stderr + allow; otherwise allow
|
|
17
|
+
*
|
|
18
|
+
* DECISION CHANNEL (post-#906): a deny is signalled by the single nested
|
|
19
|
+
* PreToolUse JSON envelope emitDeny() writes to stdout, with exit **0** —
|
|
20
|
+
* NOT by exit 2. The docs forbid the mixed form ("Exit 2 … Claude Code
|
|
21
|
+
* ignores stdout and any JSON in it"), which silently discarded the reason
|
|
22
|
+
* and surfaced to the operator as a crash. Do not reintroduce `exit 2` here.
|
|
23
|
+
* Corollary: exit 0 alone no longer distinguishes allow from deny — the
|
|
24
|
+
* envelope's presence does, and a malformed envelope fails OPEN.
|
|
17
25
|
*
|
|
18
26
|
* SECURITY-REQ-01: try/catch on main(). emitDeny on any unhandled error —
|
|
19
|
-
*
|
|
27
|
+
* fail-closed, never a bare exit 1. Null-guard readStdin() return.
|
|
20
28
|
* SECURITY-REQ-07: FALLBACK_BLOCKED includes 'git push -f' and 'drop table'
|
|
21
29
|
* (short form + case variant gaps in the original Bash fallback list).
|
|
22
30
|
* SECURITY-REQ-08: scope file read exactly once per invocation.
|
|
@@ -27,17 +35,159 @@ import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
|
27
35
|
if (!shouldRunHook('enforce-commands')) process.exit(0);
|
|
28
36
|
|
|
29
37
|
import path from 'node:path';
|
|
38
|
+
import { pathToFileURL } from 'node:url';
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// #993 — late-bound repo dependencies
|
|
42
|
+
//
|
|
43
|
+
// These used to be STATIC imports. A SyntaxError in any of them failed at ESM
|
|
44
|
+
// LINK time, before the first statement here ran: node exited 1 with 0 bytes on
|
|
45
|
+
// stdout, and the `main().catch(...)` handler at the bottom of this file was
|
|
46
|
+
// structurally unreachable (it only covers runtime errors inside `main()`).
|
|
47
|
+
// Under the exit-0 PreToolUse protocol (#906) that crash is, on the only
|
|
48
|
+
// decision-bearing channel, INDISTINGUISHABLE from an explicit `emitAllow()` —
|
|
49
|
+
// the guard failed open and SILENTLY. This is the sibling defect #992 fixed for
|
|
50
|
+
// pre-bash-destructive-guard; #993 generalises the same repair here.
|
|
51
|
+
//
|
|
52
|
+
// Binding them late (dynamic `import()` inside `bootstrap()`, below) turns that
|
|
53
|
+
// link-time crash into a catchable runtime error, which is what makes the
|
|
54
|
+
// GUARD INACTIVE banner in `_lib/guard-source-loader.mjs` reachable at all.
|
|
55
|
+
//
|
|
56
|
+
// `profile-gate.mjs` stays static on purpose — it has ZERO imports of its own
|
|
57
|
+
// and gates whether this hook runs at all.
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
/** @type {typeof import('../scripts/lib/io.mjs').readStdin} */ let readStdin;
|
|
60
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow;
|
|
61
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny;
|
|
62
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitWarn} */ let emitWarn;
|
|
63
|
+
let resolveProjectDir;
|
|
64
|
+
let readJson;
|
|
65
|
+
let findScopeFile;
|
|
66
|
+
let extractBashWriteTargets;
|
|
67
|
+
let pathMatchesPattern;
|
|
68
|
+
/**
|
|
69
|
+
* The `command-blocker.mjs` namespace, imported DIRECTLY (not via the
|
|
70
|
+
* hardening.mjs barrel — which does not carry the `headFallback` recovery, and
|
|
71
|
+
* which transitively imports command-blocker via scope-gate.mjs, so it fails
|
|
72
|
+
* anyway when command-blocker breaks). Mirrors the direct binding in
|
|
73
|
+
* pre-bash-destructive-guard / sessions-ledger-guard. Held as ONE object so the
|
|
74
|
+
* required-export list lives in exactly one place: the `requires` array on the
|
|
75
|
+
* `blocker` spec passed to `armGuard`.
|
|
76
|
+
*
|
|
77
|
+
* @type {Record<string, Function>|null}
|
|
78
|
+
*/
|
|
79
|
+
let blocker = null;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Module labels `armGuard` recovered from HEAD because the working-tree copy
|
|
83
|
+
* failed (parse error OR shape check). Non-empty ⇒ this hook is armed against
|
|
84
|
+
* COMMITTED source. Surfaced on the visible stdout channel by {@link flushNotices}
|
|
85
|
+
* (#1001) — the stderr DEGRADED banner alone is discarded under the exit-0
|
|
86
|
+
* protocol, which made a degraded ALLOW indistinguishable from a healthy one.
|
|
87
|
+
*
|
|
88
|
+
* @type {string[]}
|
|
89
|
+
*/
|
|
90
|
+
let degradedLabels = [];
|
|
91
|
+
|
|
92
|
+
const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
|
|
93
|
+
|
|
94
|
+
/** This hook's name — threaded into the guard banner (#993: no hard-wired literal). */
|
|
95
|
+
const HOOK_NAME = 'enforce-commands';
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The consequence block spliced VERBATIM into the DEGRADED and GUARD INACTIVE
|
|
99
|
+
* banners (#993), naming the enforcement this hook's outage stops applying.
|
|
100
|
+
*/
|
|
101
|
+
const GUARD_CONSEQUENCE = {
|
|
102
|
+
degraded: [
|
|
103
|
+
' Consequence: command enforcement IS still armed, but it is evaluating the',
|
|
104
|
+
' COMMITTED (HEAD) command-blocker — any uncommitted change to it is NOT in effect.',
|
|
105
|
+
],
|
|
106
|
+
inactive: [
|
|
107
|
+
' Consequence: blocked Bash commands (rm -rf, git push --force, git reset',
|
|
108
|
+
' --hard, and the wave blockedCommands list) are NOT being screened. This is',
|
|
109
|
+
' a BROKEN GUARD, not a policy decision — do not route around it, repair it.',
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Project dir for banner keying, resolved WITHOUT `platform.mjs` — that module
|
|
115
|
+
* is one of the ones that may have failed to load.
|
|
116
|
+
*
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
function bannerProjectDir() {
|
|
120
|
+
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Bind every repo dependency late, making a load failure VISIBLE (GUARD INACTIVE
|
|
125
|
+
* banner) instead of a silent exit-1 / 0-byte disarm. Throws on any load failure;
|
|
126
|
+
* the entry-point catch banners.
|
|
127
|
+
*
|
|
128
|
+
* `hardening.mjs` is bound (no headFallback — it carries relative imports) for
|
|
129
|
+
* `findScopeFile` / `extractBashWriteTargets` / `pathMatchesPattern`; the two
|
|
130
|
+
* command-matching primitives come from the direct `blocker` namespace, which is
|
|
131
|
+
* the only entry that opts into the `git show HEAD:` recovery. Because hardening
|
|
132
|
+
* transitively imports command-blocker (via scope-gate.mjs), a broken command-blocker
|
|
133
|
+
* fails hardening FIRST (it arms before the headFallback entry) — so that case
|
|
134
|
+
* degrades straight to GUARD INACTIVE, git or no git.
|
|
135
|
+
*
|
|
136
|
+
* @returns {Promise<void>}
|
|
137
|
+
*/
|
|
138
|
+
async function bootstrap() {
|
|
139
|
+
const lib = (...seg) => pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', ...seg)).href;
|
|
30
140
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
141
|
+
const { armGuard } = await import('./_lib/guard-source-loader.mjs');
|
|
142
|
+
const { modules, degraded } = await armGuard(
|
|
143
|
+
{
|
|
144
|
+
io: { specifier: lib('io.mjs') },
|
|
145
|
+
platform: { specifier: lib('platform.mjs') },
|
|
146
|
+
common: { specifier: lib('common.mjs') },
|
|
147
|
+
hardening: { specifier: lib('hardening.mjs') },
|
|
148
|
+
blocker: {
|
|
149
|
+
specifier: lib('command-blocker.mjs'),
|
|
150
|
+
headFallback: true,
|
|
151
|
+
requires: ['commandMatchesBlocked', 'suggestForCommandBlock'],
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
hookName: HOOK_NAME,
|
|
156
|
+
repoRoot: PLUGIN_ROOT,
|
|
157
|
+
projectDir: bannerProjectDir(),
|
|
158
|
+
consequence: GUARD_CONSEQUENCE,
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
|
|
163
|
+
({ resolveProjectDir } = modules.platform);
|
|
164
|
+
({ readJson } = modules.common);
|
|
165
|
+
({ findScopeFile, extractBashWriteTargets, pathMatchesPattern } = modules.hardening);
|
|
166
|
+
blocker = modules.blocker;
|
|
167
|
+
degradedLabels = degraded;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Flush the aggregated allow-with-notice channel (#1001).
|
|
172
|
+
*
|
|
173
|
+
* Notices raised during gate evaluation accumulate in `notices` and are emitted
|
|
174
|
+
* ONCE, here, on the VISIBLE stdout channel via `emitWarn` (allow-with-notice) —
|
|
175
|
+
* else a plain `emitAllow`. Both exit 0 and never return, so this is always the
|
|
176
|
+
* LAST statement on an allow path.
|
|
177
|
+
*
|
|
178
|
+
* Why aggregate instead of `emitWarn`-ing inline at the degraded site: `emitWarn`
|
|
179
|
+
* is `@returns never` (scripts/lib/io.mjs), so an inline call before the G6
|
|
180
|
+
* blocked-pattern loop would exit BEFORE any pattern was matched — turning every
|
|
181
|
+
* would-be DENY into an ALLOW-with-notice for the whole degraded session. A deny,
|
|
182
|
+
* when it fires, exits via `emitDeny` and these notices are simply dropped: DENY
|
|
183
|
+
* wins, and armGuard's stderr banner remains for CI/debug.
|
|
184
|
+
*
|
|
185
|
+
* @param {string[]} notices
|
|
186
|
+
* @returns {never}
|
|
187
|
+
*/
|
|
188
|
+
function flushNotices(notices) {
|
|
189
|
+
return notices.length > 0 ? emitWarn(notices.join('\n')) : emitAllow();
|
|
190
|
+
}
|
|
41
191
|
|
|
42
192
|
// Fallback safety list — applied when scope.blockedCommands is empty.
|
|
43
193
|
// Keep in sync with hooks/enforce-commands.sh; v3 additions (#138, SECURITY-REQ-07)
|
|
@@ -63,11 +213,29 @@ async function main() {
|
|
|
63
213
|
const command = input?.tool_input?.command;
|
|
64
214
|
if (typeof command !== 'string' || command.length === 0) return emitAllow();
|
|
65
215
|
|
|
216
|
+
// #1001 — aggregated allow-with-notice channel, opened only AFTER G1/G2 (a
|
|
217
|
+
// non-Bash tool call or an empty command is not this hook's business, and must
|
|
218
|
+
// stay a bare allow so the notice does not ride every unrelated tool call).
|
|
219
|
+
// Flushed ONCE at the end of whichever allow path is taken; see flushNotices
|
|
220
|
+
// for why an inline emitWarn here would disarm the G6 deny below.
|
|
221
|
+
const notices = [];
|
|
222
|
+
// A guard armed from HEAD (working-tree module unparseable or shape-invalid) is
|
|
223
|
+
// a visible-channel concern: the DEGRADED banner rides stderr only, which exit 0
|
|
224
|
+
// discards. armGuard already fired that banner once per session; surface it on
|
|
225
|
+
// stdout too so a degraded ALLOW is not silently indistinguishable from a
|
|
226
|
+
// healthy one.
|
|
227
|
+
if (degradedLabels.length > 0) {
|
|
228
|
+
notices.push(
|
|
229
|
+
`${HOOK_NAME}: DEGRADED — guard module(s) loaded from HEAD, not your working tree ` +
|
|
230
|
+
`(${degradedLabels.join(', ')}); uncommitted changes to them are NOT in effect. See #992.`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
66
234
|
const projectRoot = resolveProjectDir();
|
|
67
235
|
|
|
68
236
|
// G3 — no scope file → allow
|
|
69
237
|
const scopePath = findScopeFile(projectRoot);
|
|
70
|
-
if (!scopePath) return
|
|
238
|
+
if (!scopePath) return flushNotices(notices);
|
|
71
239
|
|
|
72
240
|
// SECURITY-REQ-08: read scope file exactly once; use the parsed object
|
|
73
241
|
// for all subsequent gate checks.
|
|
@@ -98,30 +266,35 @@ async function main() {
|
|
|
98
266
|
}
|
|
99
267
|
|
|
100
268
|
// G4 — gate disabled → allow
|
|
101
|
-
if (!gateOn) return
|
|
269
|
+
if (!gateOn) return flushNotices(notices);
|
|
102
270
|
// G5 — enforcement "off" → allow
|
|
103
|
-
if (enforcement === 'off') return
|
|
271
|
+
if (enforcement === 'off') return flushNotices(notices);
|
|
104
272
|
|
|
105
273
|
// G6 — determine which list to check
|
|
106
274
|
const useFallback = blockedCommands.length === 0;
|
|
107
275
|
const patternsToCheck = useFallback ? FALLBACK_BLOCKED : blockedCommands;
|
|
108
276
|
|
|
109
277
|
for (const pattern of patternsToCheck) {
|
|
110
|
-
if (commandMatchesBlocked(command, pattern)) {
|
|
278
|
+
if (blocker.commandMatchesBlocked(command, pattern)) {
|
|
111
279
|
const prefix = useFallback
|
|
112
280
|
? 'Blocked by fallback safety list'
|
|
113
281
|
: 'Blocked command';
|
|
114
282
|
const reason = `${prefix}: '${pattern}' found in command`;
|
|
115
|
-
const suggestion = suggestForCommandBlock(pattern);
|
|
283
|
+
const suggestion = blocker.suggestForCommandBlock(pattern);
|
|
116
284
|
if (enforcement === 'strict') {
|
|
117
285
|
return emitDeny(reason, suggestion);
|
|
118
286
|
}
|
|
119
|
-
|
|
287
|
+
// Aggregated, not inline: when the guard is DEGRADED the notice list is
|
|
288
|
+
// non-empty and both lines ride ONE envelope (a second emitWarn would be
|
|
289
|
+
// unreachable — emitWarn never returns). Undegraded, notices is empty and
|
|
290
|
+
// this stays the single-line warn it has always been.
|
|
291
|
+
notices.push(`${reason} — ${suggestion}`);
|
|
292
|
+
return flushNotices(notices);
|
|
120
293
|
}
|
|
121
294
|
}
|
|
122
295
|
|
|
123
296
|
// G7 — no match → allow
|
|
124
|
-
return
|
|
297
|
+
return flushNotices(notices);
|
|
125
298
|
}
|
|
126
299
|
|
|
127
300
|
/**
|
|
@@ -173,6 +346,40 @@ function targetInWaveScope(target, allowedPaths, projectRoot) {
|
|
|
173
346
|
);
|
|
174
347
|
}
|
|
175
348
|
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// Entry point (#993)
|
|
351
|
+
//
|
|
352
|
+
// TWO distinct failure classes, two distinct handlers — do NOT merge them:
|
|
353
|
+
//
|
|
354
|
+
// 1. LOAD failure (`bootstrap()` throws): the guard never armed. Under the
|
|
355
|
+
// exit-0 protocol a bare exit-1 crash with 0 bytes of stdout is, on the
|
|
356
|
+
// only decision-bearing channel, indistinguishable from an allow. Now it
|
|
357
|
+
// exits 0 (still fail-OPEN — a broken module must not brick the session,
|
|
358
|
+
// and emitDeny itself may be the module that failed to load) but SAYS SO,
|
|
359
|
+
// loudly: GUARD INACTIVE.
|
|
360
|
+
// 2. RUNTIME failure inside `main()`: pre-existing behaviour, unchanged. The
|
|
361
|
+
// guard armed and then tripped over a specific command; that fails CLOSED
|
|
362
|
+
// via emitDeny (SECURITY-REQ-01). The two paths MUST stay separate.
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
try {
|
|
365
|
+
await bootstrap();
|
|
366
|
+
} catch (loadError) {
|
|
367
|
+
try {
|
|
368
|
+
const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
|
|
369
|
+
// hookName is threaded explicitly (#993 — no hard-wired literal in the loader).
|
|
370
|
+
emitGuardInactiveBanner({ hookName: HOOK_NAME, error: loadError, consequence: GUARD_CONSEQUENCE });
|
|
371
|
+
} catch {
|
|
372
|
+
// Last resort: even the banner helper failed to load. Emit unconditionally —
|
|
373
|
+
// repeated noise beats a silent disarm.
|
|
374
|
+
process.stderr.write(
|
|
375
|
+
'🚨 enforce-commands: GUARD INACTIVE — module load failed ' +
|
|
376
|
+
`(${String(loadError?.message || loadError).split('\n')[0]}). ` +
|
|
377
|
+
'Blocked Bash commands are NOT being screened. See issue #993.\n'
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
process.exit(0); // fail-open, but no longer fail-silent
|
|
381
|
+
}
|
|
382
|
+
|
|
176
383
|
// SECURITY-REQ-01 (F-03): top-level try/catch — never let exit 1 leak.
|
|
177
384
|
main().catch((e) => {
|
|
178
385
|
emitDeny('Internal hook error — request blocked for safety', `${e?.message || e}`);
|
package/hooks/enforce-scope.mjs
CHANGED
|
@@ -48,20 +48,110 @@
|
|
|
48
48
|
|
|
49
49
|
import path from 'node:path';
|
|
50
50
|
import { promises as fs } from 'node:fs';
|
|
51
|
+
import { pathToFileURL } from 'node:url';
|
|
51
52
|
|
|
52
53
|
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
53
54
|
// #211: exit 0 immediately (silent allow) when this hook is disabled via profile/env
|
|
54
55
|
if (!shouldRunHook('enforce-scope')) process.exit(0);
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// #993 — late-bound repo dependencies
|
|
59
|
+
//
|
|
60
|
+
// These used to be STATIC imports. A SyntaxError in any of them failed at ESM
|
|
61
|
+
// LINK time, before the first statement here ran: node exited 1 with 0 bytes on
|
|
62
|
+
// stdout, and the `main().catch(...)` handler at the bottom of this file was
|
|
63
|
+
// structurally unreachable. Under the exit-0 PreToolUse protocol (#906) that
|
|
64
|
+
// crash is, on the only decision-bearing channel, INDISTINGUISHABLE from an
|
|
65
|
+
// explicit `emitAllow()` — the guard failed open and SILENTLY. This is the
|
|
66
|
+
// sibling defect #992 fixed for pre-bash-destructive-guard; #993 generalises the
|
|
67
|
+
// same repair here.
|
|
68
|
+
//
|
|
69
|
+
// Binding them late (dynamic `import()` inside `bootstrap()`, below) turns that
|
|
70
|
+
// link-time crash into a catchable runtime error, which is what makes the
|
|
71
|
+
// GUARD INACTIVE banner in `_lib/guard-source-loader.mjs` reachable at all.
|
|
72
|
+
//
|
|
73
|
+
// BANNER-ONLY (#993 D1): this hook consumes ZERO command-blocker symbols, so no
|
|
74
|
+
// module here opts into the `git show HEAD:` fallback — every load failure
|
|
75
|
+
// degrades straight to GUARD INACTIVE, never DEGRADED.
|
|
76
|
+
//
|
|
77
|
+
// `profile-gate.mjs` and `node:*` builtins stay static — they cannot be the
|
|
78
|
+
// broken repo module.
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
/** @type {typeof import('../scripts/lib/io.mjs').readStdin} */ let readStdin;
|
|
81
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow;
|
|
82
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny;
|
|
83
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitWarn} */ let emitWarn;
|
|
84
|
+
let isPathInside;
|
|
85
|
+
let relativeFromRoot;
|
|
86
|
+
let resolveProjectDir;
|
|
87
|
+
let findScopeFile;
|
|
88
|
+
let pathMatchesPattern;
|
|
89
|
+
let suggestForScopeViolation;
|
|
90
|
+
let readJson;
|
|
91
|
+
|
|
92
|
+
const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
|
|
93
|
+
|
|
94
|
+
/** This hook's name — threaded into the guard banner (#993: no hard-wired literal). */
|
|
95
|
+
const HOOK_NAME = 'enforce-scope';
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The consequence block spliced VERBATIM into the GUARD INACTIVE banner (#993),
|
|
99
|
+
* naming the enforcement this hook's outage stops applying. No `degraded` block:
|
|
100
|
+
* this hook has no headFallback module, so it can never DEGRADE — only go INACTIVE.
|
|
101
|
+
*/
|
|
102
|
+
const GUARD_CONSEQUENCE = {
|
|
103
|
+
inactive: [
|
|
104
|
+
' Consequence: Edit/Write/MultiEdit scope enforcement is OFF — writes',
|
|
105
|
+
' outside the wave allowedPaths (and outside the project root) are NOT',
|
|
106
|
+
' being blocked. This is a BROKEN GUARD, not a policy decision — do not',
|
|
107
|
+
' route around it, repair it.',
|
|
108
|
+
],
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Project dir for banner keying, resolved WITHOUT `platform.mjs` — that module
|
|
113
|
+
* is one of the ones that may have failed to load.
|
|
114
|
+
*
|
|
115
|
+
* @returns {string}
|
|
116
|
+
*/
|
|
117
|
+
function bannerProjectDir() {
|
|
118
|
+
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Bind every repo dependency late, making a load failure VISIBLE (GUARD INACTIVE
|
|
123
|
+
* banner) instead of a silent exit-1 / 0-byte disarm. Throws on any load failure;
|
|
124
|
+
* the entry-point catch banners. No entry opts into headFallback — this hook is
|
|
125
|
+
* banner-only (#993 D1).
|
|
126
|
+
*
|
|
127
|
+
* @returns {Promise<void>}
|
|
128
|
+
*/
|
|
129
|
+
async function bootstrap() {
|
|
130
|
+
const lib = (...seg) => pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', ...seg)).href;
|
|
131
|
+
|
|
132
|
+
const { armGuard } = await import('./_lib/guard-source-loader.mjs');
|
|
133
|
+
const { modules } = await armGuard(
|
|
134
|
+
{
|
|
135
|
+
io: { specifier: lib('io.mjs') },
|
|
136
|
+
pathUtils: { specifier: lib('path-utils.mjs') },
|
|
137
|
+
platform: { specifier: lib('platform.mjs') },
|
|
138
|
+
hardening: { specifier: lib('hardening.mjs') },
|
|
139
|
+
common: { specifier: lib('common.mjs') },
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
hookName: HOOK_NAME,
|
|
143
|
+
repoRoot: PLUGIN_ROOT,
|
|
144
|
+
projectDir: bannerProjectDir(),
|
|
145
|
+
consequence: GUARD_CONSEQUENCE,
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
|
|
150
|
+
({ isPathInside, relativeFromRoot } = modules.pathUtils);
|
|
151
|
+
({ resolveProjectDir } = modules.platform);
|
|
152
|
+
({ findScopeFile, pathMatchesPattern, suggestForScopeViolation } = modules.hardening);
|
|
153
|
+
({ readJson } = modules.common);
|
|
154
|
+
}
|
|
65
155
|
|
|
66
156
|
async function main() {
|
|
67
157
|
// SECURITY-REQ-01: null-guard empty stdin — treat as allow (no input = not a real hook call)
|
|
@@ -264,6 +354,40 @@ function matchesAbsoluteAllowlist(resolvedPath, allowedPaths) {
|
|
|
264
354
|
return abs.some((pat) => pathMatchesPattern(normalizedAbs, pat.split(path.sep).join('/')));
|
|
265
355
|
}
|
|
266
356
|
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// Entry point (#993)
|
|
359
|
+
//
|
|
360
|
+
// TWO distinct failure classes, two distinct handlers — do NOT merge them:
|
|
361
|
+
//
|
|
362
|
+
// 1. LOAD failure (`bootstrap()` throws): the guard never armed. Under the
|
|
363
|
+
// exit-0 protocol a bare exit-1 crash with 0 bytes of stdout is, on the
|
|
364
|
+
// only decision-bearing channel, indistinguishable from an allow. Now it
|
|
365
|
+
// exits 0 (still fail-OPEN — a broken module must not brick the session,
|
|
366
|
+
// and emitDeny itself may be the module that failed to load) but SAYS SO:
|
|
367
|
+
// GUARD INACTIVE. Banner-only — no headFallback module here (#993 D1).
|
|
368
|
+
// 2. RUNTIME failure inside `main()`: pre-existing behaviour, unchanged. The
|
|
369
|
+
// guard armed and then tripped over a specific path; that fails CLOSED via
|
|
370
|
+
// emitDeny (SECURITY-REQ-01). The two paths MUST stay separate.
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
try {
|
|
373
|
+
await bootstrap();
|
|
374
|
+
} catch (loadError) {
|
|
375
|
+
try {
|
|
376
|
+
const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
|
|
377
|
+
// hookName is threaded explicitly (#993 — no hard-wired literal in the loader).
|
|
378
|
+
emitGuardInactiveBanner({ hookName: HOOK_NAME, error: loadError, consequence: GUARD_CONSEQUENCE });
|
|
379
|
+
} catch {
|
|
380
|
+
// Last resort: even the banner helper failed to load. Emit unconditionally —
|
|
381
|
+
// repeated noise beats a silent disarm.
|
|
382
|
+
process.stderr.write(
|
|
383
|
+
'🚨 enforce-scope: GUARD INACTIVE — module load failed ' +
|
|
384
|
+
`(${String(loadError?.message || loadError).split('\n')[0]}). ` +
|
|
385
|
+
'Edit/Write/MultiEdit scope enforcement is OFF. See issue #993.\n'
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
process.exit(0); // fail-open, but no longer fail-silent
|
|
389
|
+
}
|
|
390
|
+
|
|
267
391
|
// SECURITY-REQ-01 (fail-closed): any unhandled rejection → structured deny, never bare exit 1
|
|
268
392
|
main().catch((e) => {
|
|
269
393
|
emitDeny(
|
package/hooks/hooks-codex.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "echo '🎯 Session Orchestrator v3.
|
|
10
|
+
"command": "echo '🎯 Session Orchestrator v3.20.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
|
|
11
11
|
"async": false
|
|
12
12
|
},
|
|
13
13
|
{
|
package/hooks/hooks-cursor.json
CHANGED
|
@@ -2,14 +2,23 @@
|
|
|
2
2
|
"_comment": "Cursor IDE hook mapping reference. Configure hooks in Cursor Settings > Hooks.",
|
|
3
3
|
"_note": "Cursor hooks fire at different lifecycle points than Claude Code. See docs/cursor-setup.md for details.",
|
|
4
4
|
"_limitation": "SessionStart greeting (session-start skill) is NOT available on Cursor. Cursor has no conversation-start lifecycle event equivalent to Claude Code's UserPromptSubmit hook. Session initialisation must be triggered manually.",
|
|
5
|
+
"_enforcement": "reference-only",
|
|
6
|
+
"_enforcement_note": "MACHINE-READABLE STATUS (#919): every entry below is an intended mapping, NOT live enforcement. No Cursor payload adapter exists (Pi has scripts/lib/pi-hook-bridge.mjs; Cursor has nothing), so each handler short-circuits on a Cursor-shaped payload and writes 0 bytes with exit 0. Operator decision 2026-07-31: gap registered, not closed. The machine-readable counterpart of this key is DOCUMENTED_ASYMMETRIES.handlerAsymmetries.cursor in scripts/lib/validate/check-hooks-symmetry.mjs, which Check 6 counts per event; the projection that makes that comparison non-vacuous is DOCUMENTED_ASYMMETRIES.cursorEventMap.",
|
|
7
|
+
"_issues": ["#919", "#946"],
|
|
5
8
|
"hooks": {
|
|
6
9
|
"afterFileEdit": {
|
|
7
10
|
"script": "hooks/enforce-scope.mjs",
|
|
8
|
-
"
|
|
11
|
+
"enforcement": "none",
|
|
12
|
+
"issue": "#919",
|
|
13
|
+
"mapsToClaudeEvent": "PostToolUse",
|
|
14
|
+
"note": "Post-hoc scope warning — Cursor fires AFTER the edit (not before), so this maps onto PostToolUse, never PreToolUse. Cannot prevent out-of-scope edits, only warn — and today not even that: on a Cursor afterFileEdit payload enforce-scope.mjs writes 0 bytes to stdout AND stderr with exit 0 (measured 2026-07-31), because it reads Claude Code's PreToolUse field names."
|
|
9
15
|
},
|
|
10
16
|
"beforeShellExecution": {
|
|
11
17
|
"script": "hooks/enforce-commands.mjs",
|
|
12
|
-
"
|
|
18
|
+
"enforcement": "none",
|
|
19
|
+
"issue": "#919",
|
|
20
|
+
"mapsToClaudeEvent": "PreToolUse",
|
|
21
|
+
"note": "NOT WIRED (#919) — silent no-op on a Cursor payload, NOT a block. The handler reads Claude Code's PreToolUse field names (tool_name === 'Bash', tool_input.command) and emits a Claude Code PreToolUse envelope (hookSpecificOutput.permissionDecision). Fed a Cursor beforeShellExecution payload it short-circuits at gate G1 and writes 0 bytes to stdout AND stderr with exit 0 — the harness sees no decision, so the command runs. Cursor needs an input/output adapter like scripts/lib/pi-hook-bridge.mjs; none exists. Treat this entry as the intended mapping, not as live enforcement."
|
|
13
22
|
}
|
|
14
23
|
}
|
|
15
24
|
}
|
package/hooks/hooks-pi.json
CHANGED
|
@@ -88,6 +88,16 @@
|
|
|
88
88
|
}
|
|
89
89
|
]
|
|
90
90
|
},
|
|
91
|
+
{
|
|
92
|
+
"matcher": "bash",
|
|
93
|
+
"hooks": [
|
|
94
|
+
{
|
|
95
|
+
"type": "command",
|
|
96
|
+
"command": "sh \"$PI_PLUGIN_ROOT/hooks/run-node.sh\" \"$PI_PLUGIN_ROOT/hooks/post-bash-write-verify.mjs\"",
|
|
97
|
+
"timeout": 5
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
},
|
|
91
101
|
{
|
|
92
102
|
"matcher": "*",
|
|
93
103
|
"hooks": [
|