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
|
@@ -14,13 +14,21 @@
|
|
|
14
14
|
* G2 command present + string
|
|
15
15
|
* G3 bypass: allow-destructive-ops: true in Session Config → exit 0
|
|
16
16
|
* G4 policy load: .orchestrator/policy/blocked-commands.json
|
|
17
|
-
*
|
|
17
|
+
* #972 floor/overlay merge (scripts/lib/blocked-commands-policy.mjs):
|
|
18
|
+
* pluginRoot policy = floor, cwd/projectDir policy = overlay (add or
|
|
19
|
+
* escalate only). Overlay failures fail-to-floor; only "no usable policy
|
|
20
|
+
* anywhere" keeps the historical fail-open (exit 0 + warn).
|
|
18
21
|
* G5 rule evaluation per rule in policy.rules
|
|
19
|
-
* severity:"block" →
|
|
22
|
+
* severity:"block" → deny envelope on stdout via emitDeny, exit 0 (#906)
|
|
20
23
|
* severity:"warn" → emit warning, exit 0 (allow)
|
|
21
24
|
* Special cases:
|
|
22
25
|
* git-stash-any: only warn when stash is non-empty
|
|
23
26
|
* rm-rf-destructive: path exception for .orchestrator/tmp and node_modules
|
|
27
|
+
* rule.type === 'redirect-truncate' (#983): decided by redirect TARGET
|
|
28
|
+
* via redirectRuleMatches (target-denylist globs), NEVER by the
|
|
29
|
+
* generic pattern path — its `pattern: ">"` would FP-match nearly
|
|
30
|
+
* every redirect. Unresolved targets (variable/substitution) warn
|
|
31
|
+
* on stderr (fail-visible) and never block.
|
|
24
32
|
* G6 no match → exit 0
|
|
25
33
|
*
|
|
26
34
|
* Telemetry (Epic #803 process-safety dimension): best-effort
|
|
@@ -31,54 +39,194 @@
|
|
|
31
39
|
* finalized; a telemetry failure never changes the guard's decision.
|
|
32
40
|
*/
|
|
33
41
|
|
|
34
|
-
import
|
|
35
|
-
import { resolveProjectDir, resolvePluginRoot } from '../scripts/lib/platform.mjs';
|
|
36
|
-
import { commandMatchesBlocked, tokenizeCommand } from '../scripts/lib/hardening.mjs';
|
|
37
|
-
import { readConfigFile } from '../scripts/lib/config.mjs';
|
|
38
|
-
import { readJson } from '../scripts/lib/common.mjs';
|
|
39
|
-
import { emitEvent } from '../scripts/lib/events.mjs';
|
|
40
|
-
import fs, { existsSync } from 'node:fs';
|
|
42
|
+
import fs from 'node:fs';
|
|
41
43
|
import os from 'node:os';
|
|
42
44
|
import path from 'node:path';
|
|
43
45
|
import crypto from 'node:crypto';
|
|
46
|
+
import { pathToFileURL } from 'node:url';
|
|
44
47
|
|
|
45
48
|
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
46
49
|
// #211: exit 0 immediately (silent allow) when this hook is disabled via profile/env
|
|
47
50
|
if (!shouldRunHook('pre-bash-destructive-guard')) process.exit(0);
|
|
48
51
|
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// #992 — late-bound repo dependencies
|
|
54
|
+
//
|
|
55
|
+
// These used to be STATIC imports. A SyntaxError in any of them failed at ESM
|
|
56
|
+
// LINK time, before the first statement here ran: node exited 1 with 0 bytes on
|
|
57
|
+
// stdout, and the `main().catch(...)` handler at the bottom of this file was
|
|
58
|
+
// structurally unreachable. Under the exit-0 PreToolUse protocol (#906) that
|
|
59
|
+
// crash is, on the only decision-bearing channel, INDISTINGUISHABLE from an
|
|
60
|
+
// explicit `emitAllow()` — the guard failed open and silently.
|
|
61
|
+
//
|
|
62
|
+
// Binding them late (dynamic `import()` inside `bootstrap()`, below) turns that
|
|
63
|
+
// link-time crash into a catchable runtime error, which is what makes the
|
|
64
|
+
// banner + HEAD-fallback in `_lib/guard-source-loader.mjs` reachable at all.
|
|
65
|
+
// The command-blocker half is held as a NAMESPACE object (`blocker.*`) rather
|
|
66
|
+
// than six destructured bindings — see the `blocker` docblock below for why
|
|
67
|
+
// that single change removes the shape-check drift that made a 4-of-6-missing
|
|
68
|
+
// HEAD copy read as "still armed".
|
|
69
|
+
//
|
|
70
|
+
// `profile-gate.mjs` stays static on purpose — it has ZERO imports of its own
|
|
71
|
+
// and gates whether this hook runs at all.
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
/** @type {typeof import('../scripts/lib/io.mjs').readStdin} */ let readStdin;
|
|
74
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow;
|
|
75
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny;
|
|
76
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitWarn} */ let emitWarn;
|
|
77
|
+
let resolveProjectDir;
|
|
78
|
+
let resolvePluginRoot;
|
|
79
|
+
/**
|
|
80
|
+
* The whole `command-blocker.mjs` namespace (W4 B6: one direct import path, not
|
|
81
|
+
* via the hardening.mjs barrel, which does not re-export the #982/#983
|
|
82
|
+
* primitives).
|
|
83
|
+
*
|
|
84
|
+
* Held as ONE object rather than destructured into six bindings on purpose: the
|
|
85
|
+
* required-export list then exists in exactly one place — the `requires` array
|
|
86
|
+
* on the `blocker` spec passed to `armGuard` (#993), which validates both the
|
|
87
|
+
* working-tree and the HEAD copy against it. The previous split (six names
|
|
88
|
+
* destructured here, two of them checked in the loader) is what let a HEAD copy
|
|
89
|
+
* missing four exports banner "DEGRADED — still armed" and then fail open on
|
|
90
|
+
* every command.
|
|
91
|
+
*
|
|
92
|
+
* @type {Record<string, Function>|null}
|
|
93
|
+
*/
|
|
94
|
+
let blocker = null;
|
|
95
|
+
let readConfigFile;
|
|
96
|
+
let loadEffectivePolicy;
|
|
97
|
+
let isSessionConfigHeading;
|
|
98
|
+
let emitEvent;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Labels of guard modules that loaded from HEAD rather than the working tree
|
|
102
|
+
* (#993/#995). Populated by `bootstrap()` from `armGuard`'s return; read by
|
|
103
|
+
* `main()`, which pushes a visible-channel DEGRADED notice when it is non-empty —
|
|
104
|
+
* the stderr degradation banner alone is invisible under the exit-0 protocol.
|
|
105
|
+
*
|
|
106
|
+
* @type {string[]}
|
|
107
|
+
*/
|
|
108
|
+
let degradedLabels = [];
|
|
109
|
+
|
|
110
|
+
const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
|
|
111
|
+
|
|
112
|
+
/** This hook's name — threaded into both guard banners (#993: no hard-wired literal). */
|
|
113
|
+
const HOOK_NAME = 'pre-bash-destructive-guard';
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The two consequence blocks, spliced VERBATIM into the DEGRADED and GUARD
|
|
117
|
+
* INACTIVE banners (#993). Byte-identical to the pre-#993 inline banner text so
|
|
118
|
+
* the #992 banner-visibility tests stay green.
|
|
119
|
+
*/
|
|
120
|
+
const GUARD_CONSEQUENCE = {
|
|
121
|
+
degraded: [
|
|
122
|
+
' Consequence: destructive-command enforcement IS still armed, but it is evaluating the',
|
|
123
|
+
' COMMITTED (HEAD) command lexer — any uncommitted change to that file is NOT in effect.',
|
|
124
|
+
],
|
|
125
|
+
inactive: [
|
|
126
|
+
' Consequence: destructive Bash commands (git reset --hard, rm -rf, git push --force,',
|
|
127
|
+
' git stash, redirect-truncate of protected artefacts) are NOT being blocked. This is a',
|
|
128
|
+
' BROKEN GUARD, not a policy decision — do not route around it, repair it.',
|
|
129
|
+
],
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Project dir for banner keying, resolved WITHOUT `platform.mjs` — that module
|
|
134
|
+
* is one of the ones that may have failed to load.
|
|
135
|
+
*
|
|
136
|
+
* @returns {string}
|
|
137
|
+
*/
|
|
138
|
+
function bannerProjectDir() {
|
|
139
|
+
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Bind every repo dependency. Throws on any load failure; the caller banners.
|
|
144
|
+
*
|
|
145
|
+
* Order matters only for cost: the cheap plain imports run first, so a broken
|
|
146
|
+
* `io.mjs` never pays for a pointless `git show` on `command-blocker.mjs`.
|
|
147
|
+
*
|
|
148
|
+
* @returns {Promise<void>}
|
|
149
|
+
*/
|
|
150
|
+
async function bootstrap() {
|
|
151
|
+
const lib = (...seg) => pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', ...seg)).href;
|
|
152
|
+
|
|
153
|
+
const { armGuard } = await import('./_lib/guard-source-loader.mjs');
|
|
154
|
+
// #993: one generic loader for every repo dependency. Only `blocker` opts into
|
|
155
|
+
// the `git show HEAD:` fallback (dependency-free, so data:-URL loadable) and
|
|
156
|
+
// carries the COMPLETE required-export set — validated on the working-tree AND
|
|
157
|
+
// the HEAD copy, so a partial namespace banners GUARD INACTIVE rather than
|
|
158
|
+
// arming a guard that fails open per command. A load failure of any entry
|
|
159
|
+
// throws; the caller banners GUARD INACTIVE.
|
|
160
|
+
const { modules, degraded } = await armGuard(
|
|
161
|
+
{
|
|
162
|
+
io: { specifier: lib('io.mjs') },
|
|
163
|
+
platform: { specifier: lib('platform.mjs') },
|
|
164
|
+
config: { specifier: lib('config.mjs') },
|
|
165
|
+
blockedCommandsPolicy: { specifier: lib('blocked-commands-policy.mjs') },
|
|
166
|
+
sectionExtractor: { specifier: lib('config', 'section-extractor.mjs') },
|
|
167
|
+
events: { specifier: lib('events.mjs') },
|
|
168
|
+
blocker: {
|
|
169
|
+
specifier: lib('command-blocker.mjs'),
|
|
170
|
+
headFallback: true,
|
|
171
|
+
requires: [
|
|
172
|
+
'tokenizeCommand',
|
|
173
|
+
'commandMatchesBlocked',
|
|
174
|
+
'extractRedirectTargets',
|
|
175
|
+
'redirectRuleMatches',
|
|
176
|
+
'redirectSpanEnd',
|
|
177
|
+
'resolveSegmentVerb',
|
|
178
|
+
'splitChainSegments',
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
hookName: HOOK_NAME,
|
|
184
|
+
repoRoot: PLUGIN_ROOT,
|
|
185
|
+
projectDir: bannerProjectDir(),
|
|
186
|
+
consequence: GUARD_CONSEQUENCE,
|
|
69
187
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
|
|
191
|
+
({ resolveProjectDir, resolvePluginRoot } = modules.platform);
|
|
192
|
+
({ readConfigFile } = modules.config);
|
|
193
|
+
({ loadEffectivePolicy } = modules.blockedCommandsPolicy);
|
|
194
|
+
({ isSessionConfigHeading } = modules.sectionExtractor);
|
|
195
|
+
({ emitEvent } = modules.events);
|
|
196
|
+
blocker = modules.blocker;
|
|
197
|
+
degradedLabels = degraded;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Flush the aggregated allow-with-notice channel (#995).
|
|
202
|
+
*
|
|
203
|
+
* Warnings raised during rule evaluation accumulate in `notices` and are emitted
|
|
204
|
+
* ONCE, here, on the VISIBLE stdout channel via `emitWarn` (allow-with-notice) —
|
|
205
|
+
* else a plain `emitAllow`. Both exit 0 and never return, so this is always the
|
|
206
|
+
* last statement on an allow path.
|
|
207
|
+
*
|
|
208
|
+
* Why aggregate instead of `emitWarn`-ing inline at each warn site: `emitWarn`
|
|
209
|
+
* is `@returns never`, so an inline call mid-loop would exit BEFORE a later
|
|
210
|
+
* `block` rule was evaluated — flipping a would-be DENY into an ALLOW-with-notice
|
|
211
|
+
* (measured: `bash <33×-c>` overshoots the recursion cap, and a command that
|
|
212
|
+
* both trips the cap AND carries `rm -rf src/` would wave the delete through).
|
|
213
|
+
* A block, when it fires, exits via `emitDeny` and these notices are simply
|
|
214
|
+
* dropped: DENY wins, and the stderr copies of each notice remain for CI/debug.
|
|
215
|
+
*
|
|
216
|
+
* @param {string[]} notices
|
|
217
|
+
* @returns {never}
|
|
218
|
+
*/
|
|
219
|
+
function flushNotices(notices) {
|
|
220
|
+
return notices.length > 0 ? emitWarn(notices.join('\n')) : emitAllow();
|
|
80
221
|
}
|
|
81
222
|
|
|
223
|
+
// Module-level per-path policy cache (issue #250, extended for the #972
|
|
224
|
+
// floor/overlay merge: one Map entry per policy path instead of a single-path
|
|
225
|
+
// triple, so floor and overlay invalidate independently on mtime advance).
|
|
226
|
+
// Safe because each hook invocation runs as an isolated Node subprocess —
|
|
227
|
+
// state is fresh per process, never shared across calls.
|
|
228
|
+
const _policyCache = new Map();
|
|
229
|
+
|
|
82
230
|
// ---------------------------------------------------------------------------
|
|
83
231
|
// Internal helpers
|
|
84
232
|
// ---------------------------------------------------------------------------
|
|
@@ -115,14 +263,28 @@ function resolveSessionId(input) {
|
|
|
115
263
|
}
|
|
116
264
|
|
|
117
265
|
/**
|
|
118
|
-
* Block a command:
|
|
119
|
-
*
|
|
120
|
-
*
|
|
266
|
+
* Block a command: emit the PreToolUse deny envelope via emitDeny (exit 0).
|
|
267
|
+
*
|
|
268
|
+
* Until #906 this used a raw `process.exit(2)` plus its own stdout write,
|
|
269
|
+
* justified by a claim that the multi-line message "required by the spec"
|
|
270
|
+
* could not go through emitDeny. That claim was false in both halves:
|
|
271
|
+
* - Claude Code DISCARDS stdout on exit 2 and reads stderr instead — and
|
|
272
|
+
* this function wrote nothing to stderr, so the operator saw only
|
|
273
|
+
* `hook error: … No stderr output`, i.e. what looks like a crash. This
|
|
274
|
+
* was the single worst instance of that symptom in the repo.
|
|
275
|
+
* - emitDeny preserves multi-line reasons verbatim: JSON.stringify escapes
|
|
276
|
+
* the newlines, so the exact 4-line reason below round-trips unchanged
|
|
277
|
+
* inside `permissionDecisionReason` on ONE stdout line (verified against
|
|
278
|
+
* this very reason string). The operator additionally gets the first line
|
|
279
|
+
* as the `systemMessage` headline.
|
|
121
280
|
*
|
|
122
281
|
* Emits a best-effort `orchestrator.destructive_guard.blocked` telemetry
|
|
123
|
-
* event BEFORE
|
|
282
|
+
* event BEFORE denying. Emission failure (e.g. unwritable events.jsonl path)
|
|
124
283
|
* must NEVER change the block outcome — the guard's block-decision is
|
|
125
|
-
* strictly independent of telemetry success.
|
|
284
|
+
* strictly independent of telemetry success. emitDeny never returns, so it
|
|
285
|
+
* MUST stay the last statement here.
|
|
286
|
+
*
|
|
287
|
+
* @returns {Promise<never>}
|
|
126
288
|
*/
|
|
127
289
|
async function blockCommand(pattern, ruleId, rationale, command, sessionId) {
|
|
128
290
|
const reason = [
|
|
@@ -142,36 +304,8 @@ async function blockCommand(pattern, ruleId, rationale, command, sessionId) {
|
|
|
142
304
|
// Best-effort — telemetry must never block or alter the guard decision.
|
|
143
305
|
}
|
|
144
306
|
|
|
145
|
-
// Structured deny for Claude Code hook protocol
|
|
146
|
-
|
|
147
|
-
process.exit(2);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Resolve the policy file path, searching in priority order:
|
|
152
|
-
* 1. <CWD>/.orchestrator/policy/blocked-commands.json
|
|
153
|
-
* 2. <CLAUDE_PROJECT_DIR>/.orchestrator/policy/blocked-commands.json
|
|
154
|
-
* 3. <CLAUDE_PLUGIN_ROOT>/.orchestrator/policy/blocked-commands.json
|
|
155
|
-
* Returns the first existing path, or null if none found.
|
|
156
|
-
*/
|
|
157
|
-
function resolvePolicyPath(projectDir) {
|
|
158
|
-
const candidates = [
|
|
159
|
-
path.join(process.cwd(), '.orchestrator', 'policy', 'blocked-commands.json'),
|
|
160
|
-
];
|
|
161
|
-
|
|
162
|
-
if (projectDir && projectDir !== process.cwd()) {
|
|
163
|
-
candidates.push(path.join(projectDir, '.orchestrator', 'policy', 'blocked-commands.json'));
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const pluginRoot = resolvePluginRoot();
|
|
167
|
-
if (pluginRoot) {
|
|
168
|
-
candidates.push(path.join(pluginRoot, '.orchestrator', 'policy', 'blocked-commands.json'));
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
for (const candidate of candidates) {
|
|
172
|
-
if (existsSync(candidate)) return candidate;
|
|
173
|
-
}
|
|
174
|
-
return null;
|
|
307
|
+
// Structured deny for the Claude Code PreToolUse hook protocol. Never returns.
|
|
308
|
+
emitDeny(reason);
|
|
175
309
|
}
|
|
176
310
|
|
|
177
311
|
/**
|
|
@@ -201,72 +335,123 @@ async function isGitStashNonEmpty(projectDir) {
|
|
|
201
335
|
* chained commands (`rm -rf /tmp/x; rm -rf src/`). Quote-aware via
|
|
202
336
|
* tokenizeCommand so a path with spaces inside quotes is one target.
|
|
203
337
|
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
338
|
+
* Wrapper-aware (#982 T2): the segment verb resolves through the transparent
|
|
339
|
+
* wrappers in WRAPPER_UNWRAP via resolveSegmentVerb — `sudo rm -rf /tmp/ok`,
|
|
340
|
+
* `timeout 5 rm -rf /x`, `nohup rm -rf …` parse their REAL targets instead of
|
|
341
|
+
* falling back to the empty-target conservative block. Redirect tokens and
|
|
342
|
+
* their operands are skipped (#983): they are IO targets, not rm targets.
|
|
343
|
+
*
|
|
344
|
+
* Returns BOTH halves of the operand/redirect split (merge of the two
|
|
345
|
+
* 2026-08-03 guard sessions — the two protection layers are complementary):
|
|
346
|
+
* - `targets` — the rm operands (what gets deleted). Judged against the
|
|
347
|
+
* rule's `path-allowlist` by the caller.
|
|
348
|
+
* - `writeTargets` — the files each WRITE redirect (`truncate` or `append`
|
|
349
|
+
* mode) in the same invocation clobbers-or-creates. Read redirects,
|
|
350
|
+
* here-docs/here-strings and fd duplications (`2>&1`) contribute nothing.
|
|
351
|
+
* The caller holds these to the SAME allowlist (with `/dev/null` carved
|
|
352
|
+
* out via isNullSink), because `rm -rf /tmp/ok > src/important.ts` empties
|
|
353
|
+
* a project file on the strength of an allowlisted rm operand. This is the
|
|
354
|
+
* rm-context layer; protected artefacts (`> CLAUDE.md` — `> AGENTS.md` on
|
|
355
|
+
* Codex CLI, the same truncation class) are ADDITIONALLY
|
|
356
|
+
* denied command-independently by the `redirect-truncate-protected`
|
|
357
|
+
* policy rule (rule 14).
|
|
358
|
+
*
|
|
359
|
+
* The caller treats the rm-rf rule as "allowed" only when EVERY operand and
|
|
360
|
+
* EVERY write-redirect target passes its respective check — a segment whose
|
|
361
|
+
* pattern matched but whose verb does NOT resolve to `rm` (e.g. an interpreter
|
|
362
|
+
* payload) contributes no targets and stays fail-closed.
|
|
206
363
|
*
|
|
207
364
|
* @param {string} command
|
|
208
|
-
* @returns {string[]}
|
|
365
|
+
* @returns {{targets: string[], writeTargets: string[]}}
|
|
209
366
|
*/
|
|
210
367
|
function parseRmTargets(command) {
|
|
211
|
-
const tokens = tokenizeCommand(command);
|
|
212
368
|
const targets = [];
|
|
213
|
-
|
|
369
|
+
const writeTargets = [];
|
|
214
370
|
|
|
215
|
-
|
|
216
|
-
const verb =
|
|
217
|
-
|
|
218
|
-
if (isOperator) { i++; continue; }
|
|
219
|
-
if (verb !== 'rm') { i++; continue; }
|
|
371
|
+
for (const segment of blocker.splitChainSegments(blocker.tokenizeCommand(command))) {
|
|
372
|
+
const { verb, index } = blocker.resolveSegmentVerb(segment);
|
|
373
|
+
if (verb !== 'rm') continue;
|
|
220
374
|
|
|
221
|
-
// Consume this `rm` invocation's args until the next chain operator.
|
|
222
|
-
i++; // skip `rm`
|
|
223
375
|
let seenDashDash = false;
|
|
224
|
-
|
|
225
|
-
const tok =
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
376
|
+
for (let i = index + 1; i < segment.length; i++) {
|
|
377
|
+
const tok = segment[i];
|
|
378
|
+
if (tok.redirect) {
|
|
379
|
+
const end = blocker.redirectSpanEnd(segment, i);
|
|
380
|
+
const mode = tok.redirect.mode;
|
|
381
|
+
if ((mode === 'truncate' || mode === 'append') && end > i) {
|
|
382
|
+
const operand = segment[end];
|
|
383
|
+
if (operand && !(!operand.quoted && /^(;|&&|\|\||\||&)$/.test(operand.text))) {
|
|
384
|
+
writeTargets.push(operand.text);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
i = end;
|
|
232
388
|
continue;
|
|
233
389
|
}
|
|
390
|
+
if (!seenDashDash && tok.text === '--') { seenDashDash = true; continue; }
|
|
391
|
+
// A flag is unquoted and starts with '-' (and is not the bare '-' stdin marker).
|
|
392
|
+
if (!seenDashDash && !tok.quoted && tok.text.startsWith('-') && tok.text !== '-') continue;
|
|
234
393
|
targets.push(tok.text);
|
|
235
|
-
i++;
|
|
236
394
|
}
|
|
237
395
|
}
|
|
238
396
|
|
|
239
|
-
return targets;
|
|
397
|
+
return { targets, writeTargets };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* `/dev/null` is the one write-redirect destination that destroys nothing: it is
|
|
402
|
+
* a character device, so `>` on it neither truncates nor creates a file. It is
|
|
403
|
+
* also the single most common rm-plumbing target (`rm -rf /tmp/x 2> /dev/null`).
|
|
404
|
+
*
|
|
405
|
+
* Deliberately NOT folded into isRmPathAllowed: `rm -rf /dev/null` must stay
|
|
406
|
+
* blocked — deleting the device node is destructive, writing to it is not.
|
|
407
|
+
*
|
|
408
|
+
* @param {string} targetPath
|
|
409
|
+
* @returns {boolean}
|
|
410
|
+
*/
|
|
411
|
+
function isNullSink(targetPath) {
|
|
412
|
+
return Boolean(targetPath)
|
|
413
|
+
&& path.isAbsolute(targetPath)
|
|
414
|
+
&& path.normalize(targetPath) === path.join(path.sep, 'dev', 'null');
|
|
240
415
|
}
|
|
241
416
|
|
|
242
417
|
/**
|
|
243
418
|
* Detect whether the command contains an UNQUOTED `rm` invocation carrying BOTH
|
|
244
419
|
* recursive (`-r`/`-R`/`--recursive`) AND force (`-f`/`--force`) semantics,
|
|
245
|
-
* including combined/short forms (`-rf`, `-fr`, `-r -f`)
|
|
246
|
-
* variants the literal "rm -rf"
|
|
247
|
-
*
|
|
248
|
-
* quoted
|
|
420
|
+
* including combined/short forms (`-rf`, `-fr`, `-r -f`) and the long-flag pair
|
|
421
|
+
* (`--recursive --force`). This catches flag-form variants the literal "rm -rf"
|
|
422
|
+
* pattern misses (#641 gap closure) while staying consistent with the
|
|
423
|
+
* quoted-payload guard: an `rm` that appears only inside a quoted token is NOT
|
|
424
|
+
* treated as an invocation here.
|
|
425
|
+
*
|
|
426
|
+
* Wrapper-aware (#982 T2) + redirect-skip (#983) — same segment mechanics as
|
|
427
|
+
* parseRmTargets above.
|
|
428
|
+
*
|
|
429
|
+
* Redirect operators and their targets are skipped for the same reason as in
|
|
430
|
+
* parseRmTargets: a redirect target is a filename the shell consumes, so
|
|
431
|
+
* `rm -r /tmp/x > -f` must not read `-f` as rm's force flag.
|
|
432
|
+
*
|
|
433
|
+
* This walker deliberately DISCARDS redirect spans entirely. Its question is
|
|
434
|
+
* only "does this command contain a recursive+force rm at all", i.e. whether
|
|
435
|
+
* the rm-rf rule MATCHES — it never decides allow-vs-deny. Write-redirect
|
|
436
|
+
* targets are judged once, by the `redirect-truncate-protected` policy rule
|
|
437
|
+
* (rule 14), never here — a second collector would give the guard a silently
|
|
438
|
+
* divergent opinion about the same fact.
|
|
249
439
|
*
|
|
250
440
|
* @param {string} command
|
|
251
441
|
* @returns {boolean}
|
|
252
442
|
*/
|
|
253
443
|
function commandHasRecursiveForceRm(command) {
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const verb = tok.text.replace(/^.*\//, ''); // basename
|
|
259
|
-
if (tok.quoted || verb !== 'rm') { i++; continue; }
|
|
260
|
-
|
|
261
|
-
// Scan this rm invocation's flags until the next unquoted chain operator.
|
|
262
|
-
i++; // skip `rm`
|
|
444
|
+
for (const segment of blocker.splitChainSegments(blocker.tokenizeCommand(command))) {
|
|
445
|
+
const { verb, index } = blocker.resolveSegmentVerb(segment);
|
|
446
|
+
if (verb !== 'rm' || segment[index].quoted) continue;
|
|
447
|
+
|
|
263
448
|
let recursive = false;
|
|
264
449
|
let force = false;
|
|
265
450
|
let seenDashDash = false;
|
|
266
|
-
|
|
267
|
-
const t =
|
|
268
|
-
if (
|
|
269
|
-
if (!seenDashDash && t.text === '--') { seenDashDash = true;
|
|
451
|
+
for (let i = index + 1; i < segment.length; i++) {
|
|
452
|
+
const t = segment[i];
|
|
453
|
+
if (t.redirect) { i = blocker.redirectSpanEnd(segment, i); continue; }
|
|
454
|
+
if (!seenDashDash && t.text === '--') { seenDashDash = true; continue; }
|
|
270
455
|
if (!seenDashDash && !t.quoted && t.text.startsWith('-') && t.text !== '-') {
|
|
271
456
|
if (t.text === '--recursive') recursive = true;
|
|
272
457
|
else if (t.text === '--force') force = true;
|
|
@@ -275,16 +460,138 @@ function commandHasRecursiveForceRm(command) {
|
|
|
275
460
|
if (/[rR]/.test(t.text)) recursive = true;
|
|
276
461
|
if (/f/.test(t.text)) force = true;
|
|
277
462
|
}
|
|
278
|
-
i++;
|
|
279
|
-
continue;
|
|
280
463
|
}
|
|
281
|
-
|
|
464
|
+
// non-flag arg (a target) — skip
|
|
282
465
|
}
|
|
283
466
|
if (recursive && force) return true;
|
|
284
467
|
}
|
|
285
468
|
return false;
|
|
286
469
|
}
|
|
287
470
|
|
|
471
|
+
/** Probe operator per redirect mode — see findMatchedRedirectEntry. */
|
|
472
|
+
const REDIRECT_PROBE_OPS = { truncate: '>', append: '>>', read: '<' };
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Identify WHICH resolved redirect entry a matched redirect-truncate rule hit,
|
|
476
|
+
* so the deny reason can name the target (#983). redirectRuleMatches returns
|
|
477
|
+
* only a boolean and its glob matcher is internal to command-blocker.mjs —
|
|
478
|
+
* rather than duplicate the glob logic here (it would be the third copy), each
|
|
479
|
+
* candidate target is re-probed through redirectRuleMatches with a minimal
|
|
480
|
+
* single-redirect command. Resolved targets are guaranteed free of `$` and
|
|
481
|
+
* backticks (those are reported `unresolved`), so the quoted probe round-trips
|
|
482
|
+
* the target text exactly.
|
|
483
|
+
*
|
|
484
|
+
* `repoRoot` MUST be the same value the deciding redirectRuleMatches call used
|
|
485
|
+
* (#988 T1): an absolute/`~` target only matches when the root is supplied, so
|
|
486
|
+
* dropping it here would leave `hit === null` for exactly the targets the new
|
|
487
|
+
* resolution added and the deny reason would silently fall back to the pattern.
|
|
488
|
+
*
|
|
489
|
+
* @param {object} rule — the redirect-truncate policy rule
|
|
490
|
+
* @param {Array<{ target: string|null, mode: string, unresolved?: boolean }>} entries
|
|
491
|
+
* @param {string} repoRoot — absolute repo root, forwarded to redirectRuleMatches
|
|
492
|
+
* @returns {{ target: string, mode: string }|null}
|
|
493
|
+
*/
|
|
494
|
+
function findMatchedRedirectEntry(rule, entries, repoRoot) {
|
|
495
|
+
for (const entry of entries) {
|
|
496
|
+
if (entry.unresolved) continue;
|
|
497
|
+
const op = REDIRECT_PROBE_OPS[entry.mode] ?? '>';
|
|
498
|
+
const probe = `${op} "${entry.target.replace(/[\\"]/g, '\\$&')}"`;
|
|
499
|
+
if (blocker.redirectRuleMatches(rule, probe, { repoRoot })) return entry;
|
|
500
|
+
}
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Expand a LEADING `$TMPDIR` / `${TMPDIR}` reference in an rm target token.
|
|
506
|
+
*
|
|
507
|
+
* The hook is a PreToolUse gate: it sees the raw, UNEXPANDED shell string.
|
|
508
|
+
* `rm -rf "${TMPDIR}"scratch` therefore arrives as the token `${TMPDIR}scratch`,
|
|
509
|
+
* which `path.isAbsolute()` reads as a RELATIVE path — so the #641 `wasAbsolute`
|
|
510
|
+
* gate (correctly, on its own terms) refuses to match it against any /tmp-class
|
|
511
|
+
* prefix, and the path is instead resolved against the PROJECT dir. A legitimate
|
|
512
|
+
* temp cleanup is blocked (#935 cause 1). Substituting the value the shell would
|
|
513
|
+
* have substituted, BEFORE absoluteness is judged, is the narrow fix.
|
|
514
|
+
*
|
|
515
|
+
* This does NOT by itself widen the safe set: the expansion result still has to
|
|
516
|
+
* land under a CONFINED allowlist prefix (see resolveAllowlistPrefixes), so an
|
|
517
|
+
* inherited `TMPDIR=/etc` expands to `/etc/...` and is still blocked (#642).
|
|
518
|
+
*
|
|
519
|
+
* Deliberately narrow: only TMPDIR, only in leading position, only when its
|
|
520
|
+
* value is absolute, and byte-for-byte as the shell would splice it (no invented
|
|
521
|
+
* separator — `TMPDIR=/tmp` + `${TMPDIR}x` really is `/tmpx`, not `/tmp/x`).
|
|
522
|
+
* A generic env-expander would be a far larger attack surface.
|
|
523
|
+
*
|
|
524
|
+
* @param {string} targetPath
|
|
525
|
+
* @returns {string}
|
|
526
|
+
*/
|
|
527
|
+
function expandTmpdirToken(targetPath) {
|
|
528
|
+
const m = /^\$(?:TMPDIR\b|\{TMPDIR\})/.exec(targetPath);
|
|
529
|
+
if (!m) return targetPath;
|
|
530
|
+
const value = process.env.TMPDIR || os.tmpdir();
|
|
531
|
+
if (!value || !path.isAbsolute(value)) return targetPath;
|
|
532
|
+
return value + targetPath.slice(m[0].length);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Canonicalise `absPath` by realpath-ing the deepest EXISTING ancestor and
|
|
537
|
+
* re-attaching the non-existent suffix.
|
|
538
|
+
*
|
|
539
|
+
* `fs.realpathSync.native` throws ENOENT on a path that does not exist — which
|
|
540
|
+
* is the NORMAL case here: `rm -rf` frequently targets something already gone,
|
|
541
|
+
* and a policy prefix (`$TMPDIR` of another boot session) need not exist either.
|
|
542
|
+
* The upward walk mirrors the identical pattern in hooks/enforce-scope.mjs
|
|
543
|
+
* (SECURITY-REQ-03) rather than inventing a second one.
|
|
544
|
+
*
|
|
545
|
+
* Any other fs error → return the lexical input unchanged. That is the fail-safe
|
|
546
|
+
* direction: the caller then compares lexically, i.e. exactly the pre-#935
|
|
547
|
+
* behaviour, never a broader one.
|
|
548
|
+
*
|
|
549
|
+
* @param {string} absPath
|
|
550
|
+
* @returns {string}
|
|
551
|
+
*/
|
|
552
|
+
function canonicalizeAncestors(absPath) {
|
|
553
|
+
let ancestor = absPath;
|
|
554
|
+
const segments = [];
|
|
555
|
+
for (;;) {
|
|
556
|
+
try {
|
|
557
|
+
const real = fs.realpathSync.native(ancestor);
|
|
558
|
+
return segments.length ? path.join(real, ...segments.reverse()) : real;
|
|
559
|
+
} catch (err) {
|
|
560
|
+
// ENOENT: nothing at this level yet. ENOTDIR: a FILE sits in the chain.
|
|
561
|
+
// Both mean "keep walking up"; anything else (ELOOP, EACCES) → lexical.
|
|
562
|
+
if (err?.code !== 'ENOENT' && err?.code !== 'ENOTDIR') return absPath;
|
|
563
|
+
const parent = path.dirname(ancestor);
|
|
564
|
+
if (parent === ancestor) return absPath; // reached the fs root, nothing existed
|
|
565
|
+
segments.push(path.basename(ancestor));
|
|
566
|
+
ancestor = parent;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Canonical LOCATION of an `rm` target.
|
|
573
|
+
*
|
|
574
|
+
* `rm -rf X` removes X ITSELF: when X is a symlink the link is unlinked and what
|
|
575
|
+
* it points at is untouched. So the final segment must NOT be symlink-resolved —
|
|
576
|
+
* only its parent chain. Resolving the leaf would be both wrong about what rm
|
|
577
|
+
* does and unsafe: `<project>/link-to-tmp` would canonicalise to /private/tmp and
|
|
578
|
+
* read as a safe temp target while the delete lands on project content.
|
|
579
|
+
*
|
|
580
|
+
* Canonicalising the parent chain keeps the judgement honest in both directions:
|
|
581
|
+
* - `/tmp/link-to-etc/x` canonicalises OUT of the temp allowlist → blocked.
|
|
582
|
+
* The pre-#935 literal prefix match allowed it (measured, real hole).
|
|
583
|
+
* - `$TMPDIR/x` and its `/private/var/folders/...` canonical spelling collapse
|
|
584
|
+
* onto the same string → both allowed, no literal-form guessing.
|
|
585
|
+
*
|
|
586
|
+
* @param {string} abs — already `path.normalize`d absolute path
|
|
587
|
+
* @returns {string}
|
|
588
|
+
*/
|
|
589
|
+
function canonicalizeRmTarget(abs) {
|
|
590
|
+
const parent = path.dirname(abs);
|
|
591
|
+
if (parent === abs) return abs; // filesystem root
|
|
592
|
+
return path.join(canonicalizeAncestors(parent), path.basename(abs));
|
|
593
|
+
}
|
|
594
|
+
|
|
288
595
|
/**
|
|
289
596
|
* Return true when a single path is a safe `rm -rf` target.
|
|
290
597
|
*
|
|
@@ -293,7 +600,7 @@ function commandHasRecursiveForceRm(command) {
|
|
|
293
600
|
* - <projectRoot>/node_modules (any depth)
|
|
294
601
|
* - /tmp/ (any depth) — agent-owned scratch
|
|
295
602
|
* - /private/tmp/ (any depth) — macOS canonical /tmp
|
|
296
|
-
* - resolved os.tmpdir() / $TMPDIR (any depth)
|
|
603
|
+
* - resolved os.tmpdir() / $TMPDIR (any depth), in either spelling
|
|
297
604
|
*
|
|
298
605
|
* The /tmp-class prefixes come from the rule's optional `path-allowlist` and are
|
|
299
606
|
* resolved at runtime here.
|
|
@@ -307,12 +614,17 @@ function isRmPathAllowed(targetPath, projectDir, ruleAllowlist = []) {
|
|
|
307
614
|
if (!targetPath) return false;
|
|
308
615
|
|
|
309
616
|
const base = projectDir || process.cwd();
|
|
310
|
-
|
|
617
|
+
// Restore the shell's own substitution before judging absoluteness (#935).
|
|
618
|
+
const effective = expandTmpdirToken(targetPath);
|
|
619
|
+
const wasAbsolute = path.isAbsolute(effective);
|
|
311
620
|
|
|
312
621
|
// Project-relative safe dirs (always allowed, independent of the rule allowlist).
|
|
622
|
+
// Deliberately LEXICAL: a project-relative candidate is never symlink-resolved,
|
|
623
|
+
// otherwise a link inside the project pointing at /tmp would read as safe and
|
|
624
|
+
// `rm -rf <that link>` — which destroys project content — would be allowed.
|
|
313
625
|
const abs = wasAbsolute
|
|
314
|
-
? path.normalize(
|
|
315
|
-
: path.resolve(base,
|
|
626
|
+
? path.normalize(effective)
|
|
627
|
+
: path.resolve(base, effective);
|
|
316
628
|
|
|
317
629
|
const safeProjectDirs = [
|
|
318
630
|
path.join(base, '.orchestrator', 'tmp'),
|
|
@@ -328,9 +640,14 @@ function isRmPathAllowed(targetPath, projectDir, ruleAllowlist = []) {
|
|
|
328
640
|
// /tmp prefix just because the project dir itself happens to live under /tmp
|
|
329
641
|
// (the case on CI runners where os.tmpdir() === /tmp). #641.
|
|
330
642
|
if (wasAbsolute) {
|
|
643
|
+
// Compare CANONICAL forms on both sides. Literal matching made the verdict
|
|
644
|
+
// depend on which spelling of the same directory was typed (`/var/folders/…`
|
|
645
|
+
// allowed, `/private/var/folders/…` blocked — #935 cause 2) and let a symlink
|
|
646
|
+
// under /tmp launder a non-temp destination into the allowlist.
|
|
647
|
+
const canonTarget = canonicalizeRmTarget(abs);
|
|
331
648
|
for (const prefix of resolveAllowlistPrefixes(ruleAllowlist)) {
|
|
332
649
|
// The target must be the prefix dir itself or a descendant of it.
|
|
333
|
-
if (
|
|
650
|
+
if (canonTarget === prefix || canonTarget.startsWith(prefix + path.sep)) return true;
|
|
334
651
|
}
|
|
335
652
|
}
|
|
336
653
|
|
|
@@ -342,23 +659,51 @@ function isRmPathAllowed(targetPath, projectDir, ruleAllowlist = []) {
|
|
|
342
659
|
* prefixes. `$TMPDIR` expands to env.TMPDIR (if set) and os.tmpdir(); literal
|
|
343
660
|
* paths are normalised. Trailing slashes are stripped for prefix comparison.
|
|
344
661
|
*
|
|
662
|
+
* Every prefix is emitted in BOTH its lexical and its canonical (realpath)
|
|
663
|
+
* spelling, because the target it is compared against is canonicalised too
|
|
664
|
+
* (see canonicalizeRmTarget) — and when realpath is unavailable both sides fall
|
|
665
|
+
* back to lexical together, so the pair never drifts apart.
|
|
666
|
+
*
|
|
345
667
|
* @param {string[]} ruleAllowlist
|
|
346
668
|
* @returns {string[]} normalised absolute prefixes (no trailing slash)
|
|
347
669
|
*/
|
|
348
670
|
function resolveAllowlistPrefixes(ruleAllowlist) {
|
|
349
671
|
const out = new Set();
|
|
672
|
+
const strip = (p) => path.normalize(p).replace(/[/\\]+$/, '');
|
|
673
|
+
|
|
674
|
+
// Canonical macOS spellings are listed EXPLICITLY next to their symlink form:
|
|
675
|
+
// /tmp → /private/tmp and /var/folders → /private/var/folders. A TMPDIR handed
|
|
676
|
+
// to us already canonicalised (what fs.realpath returns on macOS) previously
|
|
677
|
+
// failed this confinement check and contributed NO prefix at all, so EVERY
|
|
678
|
+
// $TMPDIR target was blocked (#935 cause 2).
|
|
679
|
+
const tempRoots = ['/tmp', '/private/tmp', '/var/folders', '/private/var/folders']
|
|
680
|
+
.map((p) => path.normalize(p));
|
|
681
|
+
const underTempRoot = (p) =>
|
|
682
|
+
tempRoots.some((root) => p === root || p.startsWith(root + path.sep));
|
|
683
|
+
|
|
684
|
+
// Operator-authored, VCS-reviewed policy literals (`/tmp/`, `/private/tmp/`).
|
|
350
685
|
const add = (p) => {
|
|
351
686
|
if (!p) return;
|
|
352
|
-
const
|
|
353
|
-
if (
|
|
687
|
+
const lex = strip(p);
|
|
688
|
+
if (!lex) return;
|
|
689
|
+
out.add(lex);
|
|
690
|
+
const canon = strip(canonicalizeAncestors(lex));
|
|
691
|
+
if (canon) out.add(canon);
|
|
354
692
|
};
|
|
355
|
-
|
|
693
|
+
|
|
694
|
+
// Environment-derived (`$TMPDIR`) — attacker-influencable, hence DOUBLE
|
|
695
|
+
// confinement: the value must sit under a temp root both as written AND after
|
|
696
|
+
// symlink resolution. Checking only the lexical form would let a `TMPDIR`
|
|
697
|
+
// pointing at a symlink under /tmp (e.g. /tmp/evil → <project>) promote the
|
|
698
|
+
// project itself to an allowlisted prefix (#642 confinement, extended).
|
|
356
699
|
const addTemp = (p) => {
|
|
357
700
|
if (!p || !path.isAbsolute(p)) return;
|
|
358
|
-
const
|
|
359
|
-
if (
|
|
360
|
-
|
|
361
|
-
|
|
701
|
+
const lex = strip(p);
|
|
702
|
+
if (!lex || !underTempRoot(lex)) return;
|
|
703
|
+
const canon = strip(canonicalizeAncestors(lex));
|
|
704
|
+
if (!canon || !underTempRoot(canon)) return;
|
|
705
|
+
out.add(lex);
|
|
706
|
+
out.add(canon);
|
|
362
707
|
};
|
|
363
708
|
|
|
364
709
|
for (const entry of Array.isArray(ruleAllowlist) ? ruleAllowlist : []) {
|
|
@@ -392,6 +737,22 @@ async function main() {
|
|
|
392
737
|
const projectDir = resolveProjectDir();
|
|
393
738
|
const sessionId = resolveSessionId(input);
|
|
394
739
|
|
|
740
|
+
// #995 — aggregated allow-with-notice channel. Warn-severity matches and
|
|
741
|
+
// fail-visible markers accumulate here (stderr copies are kept for parity) and
|
|
742
|
+
// flush ONCE, on the visible stdout channel, only after EVERY rule has been
|
|
743
|
+
// evaluated. See flushNotices for why inline emitWarn would fail open.
|
|
744
|
+
const notices = [];
|
|
745
|
+
// A guard armed from HEAD (working-tree module unparseable) is a visible-channel
|
|
746
|
+
// concern too: the DEGRADED banner rides stderr only, which exit-0 discards. The
|
|
747
|
+
// stderr banner already fired once-per-session inside armGuard; surface it on
|
|
748
|
+
// stdout so a degraded ALLOW is not silently indistinguishable from a healthy one.
|
|
749
|
+
if (degradedLabels.length > 0) {
|
|
750
|
+
notices.push(
|
|
751
|
+
`${HOOK_NAME}: DEGRADED — guard module(s) loaded from HEAD, not your working tree ` +
|
|
752
|
+
`(${degradedLabels.join(', ')}); uncommitted changes to them are NOT in effect. See #992.`
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
|
|
395
756
|
// G3 — bypass: allow-destructive-ops: true in Session Config
|
|
396
757
|
// Note: parseSessionConfig only returns known fields; allow-destructive-ops is
|
|
397
758
|
// a new field, so we parse the raw markdown for it directly.
|
|
@@ -402,7 +763,10 @@ async function main() {
|
|
|
402
763
|
const lines = mdContent.split(/\r?\n/);
|
|
403
764
|
let inConfig = false;
|
|
404
765
|
for (const line of lines) {
|
|
405
|
-
|
|
766
|
+
// SSOT predicate (#968) — never re-derive this comparison. A local copy
|
|
767
|
+
// that drifts LOOSER than the extractor silently disagrees with the
|
|
768
|
+
// runtime about where the config block starts.
|
|
769
|
+
if (isSessionConfigHeading(line)) { inConfig = true; continue; }
|
|
406
770
|
if (inConfig && /^## /.test(line)) break;
|
|
407
771
|
if (inConfig) {
|
|
408
772
|
const m = line.match(/^\s*(?:-\s+\*\*)?allow-destructive-ops(?::\*\*)?\s*:\s*(\S+)/);
|
|
@@ -416,42 +780,82 @@ async function main() {
|
|
|
416
780
|
// No config file or parse error — proceed to policy check
|
|
417
781
|
}
|
|
418
782
|
|
|
419
|
-
// G4 — policy load
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
process.stderr.write(
|
|
434
|
-
'⚠ pre-bash-destructive-guard: policy file is malformed (invalid JSON) — skipping guard\n'
|
|
435
|
-
);
|
|
436
|
-
return emitAllow();
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
if (!policy || !Array.isArray(policy.rules)) {
|
|
440
|
-
process.stderr.write(
|
|
441
|
-
'⚠ pre-bash-destructive-guard: policy file missing .rules array — skipping guard\n'
|
|
442
|
-
);
|
|
443
|
-
return emitAllow();
|
|
783
|
+
// G4 — policy load (#972: floor/overlay merge, not first-hit-wins).
|
|
784
|
+
// The plugin-root policy is the FLOOR; a cwd/projectDir policy is an OVERLAY
|
|
785
|
+
// that can only add rules or escalate severity — an empty or malformed
|
|
786
|
+
// consumer policy fails TO THE FLOOR instead of silently disarming the guard.
|
|
787
|
+
// Only when NO usable policy exists at all does the guard keep its documented
|
|
788
|
+
// fail-open (rules === null → emitAllow with a stderr warning).
|
|
789
|
+
const { rules, warnings } = await loadEffectivePolicy({
|
|
790
|
+
cwd: process.cwd(),
|
|
791
|
+
projectDir,
|
|
792
|
+
pluginRoot: resolvePluginRoot(),
|
|
793
|
+
cache: _policyCache,
|
|
794
|
+
});
|
|
795
|
+
for (const warning of warnings) {
|
|
796
|
+
process.stderr.write(`⚠ pre-bash-destructive-guard: ${warning}\n`);
|
|
444
797
|
}
|
|
798
|
+
// No usable policy anywhere → documented total fail-open. Flush any notice
|
|
799
|
+
// already queued (a DEGRADED head-fallback notice), else a plain allow: the
|
|
800
|
+
// policy-load warnings above stay stderr-only, as they always have.
|
|
801
|
+
if (!Array.isArray(rules)) return flushNotices(notices);
|
|
445
802
|
|
|
446
803
|
// G5 — rule evaluation
|
|
447
|
-
for (const rule of
|
|
804
|
+
for (const rule of rules) {
|
|
448
805
|
const { id, pattern, severity, rationale = '' } = rule;
|
|
449
806
|
|
|
807
|
+
// #983 — redirect-truncate rules are decided by redirect TARGET via
|
|
808
|
+
// redirectRuleMatches, NEVER by the generic pattern path below: their
|
|
809
|
+
// `pattern: ">"` substring-matches virtually every redirect (measured
|
|
810
|
+
// interim FP: `bash -c 'echo a > b'` denied), so this branch must fully
|
|
811
|
+
// shadow the pattern match for this rule class.
|
|
812
|
+
if (rule.type === 'redirect-truncate') {
|
|
813
|
+
const modes = new Set(
|
|
814
|
+
Array.isArray(rule.modes) && rule.modes.length > 0 ? rule.modes : ['truncate']
|
|
815
|
+
);
|
|
816
|
+
const collected = blocker.extractRedirectTargets(command);
|
|
817
|
+
const entries = collected.filter((e) => modes.has(e.mode));
|
|
818
|
+
// Recursion-cap markers (#988 T2) carry `mode: null` by construction — a
|
|
819
|
+
// bare `modes.has(e.mode)` filter drops them, which would make the new
|
|
820
|
+
// marker unobservable here. Keep them alongside the mode-matching ones.
|
|
821
|
+
const unresolved = collected.filter(
|
|
822
|
+
(e) => e.unresolved && (e.mode === null || modes.has(e.mode))
|
|
823
|
+
);
|
|
824
|
+
if (unresolved.length > 0) {
|
|
825
|
+
// Variable/substitution operands are never match candidates (#641 FP
|
|
826
|
+
// class) — surface them instead of guessing; never block on a guess.
|
|
827
|
+
// Same for a payload subtree a recursion cap cut off: the cap stays,
|
|
828
|
+
// its effect stops being silent.
|
|
829
|
+
const reasons = [
|
|
830
|
+
...new Set(unresolved.map((e) => e.reason ?? 'variable/substitution')),
|
|
831
|
+
].join(', ');
|
|
832
|
+
// #995 — the #988-T2 recursion-cap marker used to reach stderr only, so
|
|
833
|
+
// under exit-0 a budget-exhausted command (`bash <33×-c>` hiding
|
|
834
|
+
// `> CLAUDE.md`) allowed with an INVISIBLE notice. Aggregate onto the
|
|
835
|
+
// visible channel; keep the stderr copy for parity.
|
|
836
|
+
const msg = `pre-bash-destructive-guard: unresolved redirect target (${reasons}) — not matched (fail-visible)`;
|
|
837
|
+
process.stderr.write(`⚠ ${msg}\n`);
|
|
838
|
+
notices.push(msg);
|
|
839
|
+
}
|
|
840
|
+
if (!blocker.redirectRuleMatches(rule, command, { repoRoot: projectDir })) continue;
|
|
841
|
+
if (severity !== 'block') {
|
|
842
|
+
const msg = `pre-bash-destructive-guard: redirect target matched (rule: ${id}) — ${rationale}`;
|
|
843
|
+
process.stderr.write(`⚠ ${msg}\n`);
|
|
844
|
+
notices.push(msg); // #995 — visible on the allow-with-notice channel
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
// Reason stays short (stdout-budget): operator + target, never the command.
|
|
848
|
+
const hit = findMatchedRedirectEntry(rule, entries, projectDir);
|
|
849
|
+
const label = hit ? `${REDIRECT_PROBE_OPS[hit.mode] ?? '>'} ${hit.target}` : pattern;
|
|
850
|
+
await blockCommand(label, id, rationale, command, sessionId);
|
|
851
|
+
continue; // unreachable (blockCommand never returns) — kept for clarity
|
|
852
|
+
}
|
|
853
|
+
|
|
450
854
|
// The rm-rf-destructive rule also fires for recursive+force rm flag variants
|
|
451
855
|
// the literal "rm -rf" pattern misses (`rm -r -f`, `rm -fr`) — #641 gap closure.
|
|
452
856
|
const matched = id === 'rm-rf-destructive'
|
|
453
|
-
? (commandMatchesBlocked(command, pattern) || commandHasRecursiveForceRm(command))
|
|
454
|
-
: commandMatchesBlocked(command, pattern);
|
|
857
|
+
? (blocker.commandMatchesBlocked(command, pattern) || commandHasRecursiveForceRm(command))
|
|
858
|
+
: blocker.commandMatchesBlocked(command, pattern);
|
|
455
859
|
if (!matched) continue;
|
|
456
860
|
|
|
457
861
|
if (severity === 'warn') {
|
|
@@ -463,9 +867,9 @@ async function main() {
|
|
|
463
867
|
continue;
|
|
464
868
|
}
|
|
465
869
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
);
|
|
870
|
+
const msg = `pre-bash-destructive-guard: '${pattern}' (rule: ${id}) — ${rationale}`;
|
|
871
|
+
process.stderr.write(`⚠ ${msg}\n`);
|
|
872
|
+
notices.push(msg); // #995 — visible on the allow-with-notice channel
|
|
469
873
|
// Best-effort telemetry — must never affect the warn/allow outcome.
|
|
470
874
|
try {
|
|
471
875
|
await emitEvent('orchestrator.destructive_guard.warned', {
|
|
@@ -484,14 +888,25 @@ async function main() {
|
|
|
484
888
|
// Special: rm-rf-destructive — path exception
|
|
485
889
|
if (id === 'rm-rf-destructive') {
|
|
486
890
|
const ruleAllowlist = Array.isArray(rule['path-allowlist']) ? rule['path-allowlist'] : [];
|
|
487
|
-
const targets = parseRmTargets(command);
|
|
891
|
+
const { targets, writeTargets } = parseRmTargets(command);
|
|
488
892
|
// Allow ONLY when there is at least one target AND every target is
|
|
489
893
|
// allowlisted. An unparseable command (no targets) or any non-allowlisted
|
|
490
894
|
// target → block (conservative). This makes mixed chains like
|
|
491
895
|
// `rm -rf /tmp/x; rm -rf src/` block on the src/ target.
|
|
896
|
+
//
|
|
897
|
+
// Every WRITE-redirect target of the same invocation must clear the SAME
|
|
898
|
+
// allowlist (with /dev/null carved out): `>` truncates its target before
|
|
899
|
+
// rm ever runs, so without this the allow path waved through
|
|
900
|
+
// `rm -rf /tmp/ok > src/important.ts` on the strength of an allowlisted
|
|
901
|
+
// rm operand. Protected artefacts (`> CLAUDE.md`) are ADDITIONALLY
|
|
902
|
+
// covered command-independently by rule 14 (redirect-truncate-protected)
|
|
903
|
+
// — two complementary layers from the two 2026-08-03 guard sessions.
|
|
492
904
|
const allAllowed =
|
|
493
905
|
targets.length > 0 &&
|
|
494
|
-
targets.every((t) => isRmPathAllowed(t, projectDir, ruleAllowlist))
|
|
906
|
+
targets.every((t) => isRmPathAllowed(t, projectDir, ruleAllowlist)) &&
|
|
907
|
+
writeTargets.every(
|
|
908
|
+
(t) => isNullSink(t) || isRmPathAllowed(t, projectDir, ruleAllowlist)
|
|
909
|
+
);
|
|
495
910
|
if (allAllowed) {
|
|
496
911
|
// Safe paths only (.orchestrator/tmp, node_modules, /tmp, $TMPDIR) — allow
|
|
497
912
|
continue;
|
|
@@ -502,8 +917,45 @@ async function main() {
|
|
|
502
917
|
// Unknown severity → skip (conservative allow for unknown future severities)
|
|
503
918
|
}
|
|
504
919
|
|
|
505
|
-
// G6 — no blocking match
|
|
506
|
-
|
|
920
|
+
// G6 — no blocking match. Flush the aggregated allow-with-notice channel
|
|
921
|
+
// (#995): emitWarn if any notice queued (visible), else a silent allow.
|
|
922
|
+
return flushNotices(notices);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// ---------------------------------------------------------------------------
|
|
926
|
+
// Entry point (#992)
|
|
927
|
+
//
|
|
928
|
+
// TWO distinct failure classes, two distinct banners — do not merge them:
|
|
929
|
+
//
|
|
930
|
+
// 1. LOAD failure (`bootstrap()` throws): the guard never armed. Nothing was
|
|
931
|
+
// evaluated, so the fail-open is total. This used to be a bare exit-1
|
|
932
|
+
// crash with 0 bytes of stdout — indistinguishable from an allow, and
|
|
933
|
+
// therefore invisible. Now it exits 0 (still fail-open, so a broken module
|
|
934
|
+
// cannot brick the session) but SAYS SO, loudly, once per session.
|
|
935
|
+
// 2. RUNTIME failure inside `main()`: pre-existing behaviour, unchanged.
|
|
936
|
+
// The guard armed and then tripped over a specific command; that is a
|
|
937
|
+
// narrower blast radius and keeps its historical `internal error` line.
|
|
938
|
+
// ---------------------------------------------------------------------------
|
|
939
|
+
try {
|
|
940
|
+
await bootstrap();
|
|
941
|
+
} catch (loadError) {
|
|
942
|
+
try {
|
|
943
|
+
const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
|
|
944
|
+
// Unthrottled by design: EVERY call in an unarmed session says so (#992
|
|
945
|
+
// hardening — the once-per-session marker it used to pass through was
|
|
946
|
+
// suppressible by a bare `touch` on a derivable tmp path). hookName is
|
|
947
|
+
// threaded explicitly (#993 — no hard-wired literal in the loader).
|
|
948
|
+
emitGuardInactiveBanner({ hookName: HOOK_NAME, error: loadError, consequence: GUARD_CONSEQUENCE });
|
|
949
|
+
} catch {
|
|
950
|
+
// Last resort: even the banner helper failed to load. Emit unconditionally
|
|
951
|
+
// (no once-per-session keying) — repeated noise beats a silent disarm.
|
|
952
|
+
process.stderr.write(
|
|
953
|
+
'🚨 pre-bash-destructive-guard: GUARD INACTIVE — module load failed ' +
|
|
954
|
+
`(${String(loadError?.message || loadError).split('\n')[0]}). ` +
|
|
955
|
+
'Destructive Bash commands are NOT being blocked. See issue #992.\n'
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
process.exit(0); // fail-open, but no longer fail-silent
|
|
507
959
|
}
|
|
508
960
|
|
|
509
961
|
// Top-level error handler — never let exit 1 leak
|