session-orchestrator 3.17.0 → 3.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +105 -412
- package/README.md +12 -9
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +244 -9
- package/docs/session-config-template.md +39 -3
- package/hooks/_lib/guard-source-loader.mjs +467 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +23 -0
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +5 -2
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -13
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +73 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- package/templates/_shared/loop.md +4 -4
|
@@ -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,109 @@
|
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
+
let resolveProjectDir;
|
|
77
|
+
let resolvePluginRoot;
|
|
78
|
+
/**
|
|
79
|
+
* The whole `command-blocker.mjs` namespace (W4 B6: one direct import path, not
|
|
80
|
+
* via the hardening.mjs barrel, which does not re-export the #982/#983
|
|
81
|
+
* primitives).
|
|
82
|
+
*
|
|
83
|
+
* Held as ONE object rather than destructured into six bindings on purpose: the
|
|
84
|
+
* required-export list then exists in exactly one place —
|
|
85
|
+
* `COMMAND_BLOCKER_EXPORTS` in `_lib/guard-source-loader.mjs`, which validates
|
|
86
|
+
* both the working-tree and the HEAD copy against it. The previous split (six
|
|
87
|
+
* names destructured here, two of them checked there) is what let a HEAD copy
|
|
88
|
+
* missing four exports banner "DEGRADED — still armed" and then fail open on
|
|
89
|
+
* every command.
|
|
90
|
+
*
|
|
91
|
+
* @type {Record<string, Function>|null}
|
|
92
|
+
*/
|
|
93
|
+
let blocker = null;
|
|
94
|
+
let readConfigFile;
|
|
95
|
+
let loadEffectivePolicy;
|
|
96
|
+
let isSessionConfigHeading;
|
|
97
|
+
let emitEvent;
|
|
98
|
+
|
|
99
|
+
const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Project dir for banner keying, resolved WITHOUT `platform.mjs` — that module
|
|
103
|
+
* is one of the ones that may have failed to load.
|
|
104
|
+
*
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
function bannerProjectDir() {
|
|
108
|
+
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
80
109
|
}
|
|
81
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Bind every repo dependency. Throws on any load failure; the caller banners.
|
|
113
|
+
*
|
|
114
|
+
* Order matters only for cost: the cheap plain imports run first, so a broken
|
|
115
|
+
* `io.mjs` never pays for a pointless `git show` on `command-blocker.mjs`.
|
|
116
|
+
*
|
|
117
|
+
* @returns {Promise<void>}
|
|
118
|
+
*/
|
|
119
|
+
async function bootstrap() {
|
|
120
|
+
({ readStdin, emitAllow, emitDeny } = await import('../scripts/lib/io.mjs'));
|
|
121
|
+
({ resolveProjectDir, resolvePluginRoot } = await import('../scripts/lib/platform.mjs'));
|
|
122
|
+
({ readConfigFile } = await import('../scripts/lib/config.mjs'));
|
|
123
|
+
({ loadEffectivePolicy } = await import('../scripts/lib/blocked-commands-policy.mjs'));
|
|
124
|
+
({ isSessionConfigHeading } = await import('../scripts/lib/config/section-extractor.mjs'));
|
|
125
|
+
({ emitEvent } = await import('../scripts/lib/events.mjs'));
|
|
126
|
+
|
|
127
|
+
const { loadCommandBlocker } = await import('./_lib/guard-source-loader.mjs');
|
|
128
|
+
// Throws unless the loaded namespace carries the COMPLETE required export set
|
|
129
|
+
// (working-tree copy or HEAD fallback alike) — the caller then banners
|
|
130
|
+
// GUARD INACTIVE rather than arming a guard that cannot evaluate.
|
|
131
|
+
({ module: blocker } = await loadCommandBlocker({
|
|
132
|
+
specifier: pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'command-blocker.mjs')).href,
|
|
133
|
+
repoRoot: PLUGIN_ROOT,
|
|
134
|
+
projectDir: bannerProjectDir(),
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Module-level per-path policy cache (issue #250, extended for the #972
|
|
139
|
+
// floor/overlay merge: one Map entry per policy path instead of a single-path
|
|
140
|
+
// triple, so floor and overlay invalidate independently on mtime advance).
|
|
141
|
+
// Safe because each hook invocation runs as an isolated Node subprocess —
|
|
142
|
+
// state is fresh per process, never shared across calls.
|
|
143
|
+
const _policyCache = new Map();
|
|
144
|
+
|
|
82
145
|
// ---------------------------------------------------------------------------
|
|
83
146
|
// Internal helpers
|
|
84
147
|
// ---------------------------------------------------------------------------
|
|
@@ -115,14 +178,28 @@ function resolveSessionId(input) {
|
|
|
115
178
|
}
|
|
116
179
|
|
|
117
180
|
/**
|
|
118
|
-
* Block a command:
|
|
119
|
-
*
|
|
120
|
-
*
|
|
181
|
+
* Block a command: emit the PreToolUse deny envelope via emitDeny (exit 0).
|
|
182
|
+
*
|
|
183
|
+
* Until #906 this used a raw `process.exit(2)` plus its own stdout write,
|
|
184
|
+
* justified by a claim that the multi-line message "required by the spec"
|
|
185
|
+
* could not go through emitDeny. That claim was false in both halves:
|
|
186
|
+
* - Claude Code DISCARDS stdout on exit 2 and reads stderr instead — and
|
|
187
|
+
* this function wrote nothing to stderr, so the operator saw only
|
|
188
|
+
* `hook error: … No stderr output`, i.e. what looks like a crash. This
|
|
189
|
+
* was the single worst instance of that symptom in the repo.
|
|
190
|
+
* - emitDeny preserves multi-line reasons verbatim: JSON.stringify escapes
|
|
191
|
+
* the newlines, so the exact 4-line reason below round-trips unchanged
|
|
192
|
+
* inside `permissionDecisionReason` on ONE stdout line (verified against
|
|
193
|
+
* this very reason string). The operator additionally gets the first line
|
|
194
|
+
* as the `systemMessage` headline.
|
|
121
195
|
*
|
|
122
196
|
* Emits a best-effort `orchestrator.destructive_guard.blocked` telemetry
|
|
123
|
-
* event BEFORE
|
|
197
|
+
* event BEFORE denying. Emission failure (e.g. unwritable events.jsonl path)
|
|
124
198
|
* must NEVER change the block outcome — the guard's block-decision is
|
|
125
|
-
* strictly independent of telemetry success.
|
|
199
|
+
* strictly independent of telemetry success. emitDeny never returns, so it
|
|
200
|
+
* MUST stay the last statement here.
|
|
201
|
+
*
|
|
202
|
+
* @returns {Promise<never>}
|
|
126
203
|
*/
|
|
127
204
|
async function blockCommand(pattern, ruleId, rationale, command, sessionId) {
|
|
128
205
|
const reason = [
|
|
@@ -142,36 +219,8 @@ async function blockCommand(pattern, ruleId, rationale, command, sessionId) {
|
|
|
142
219
|
// Best-effort — telemetry must never block or alter the guard decision.
|
|
143
220
|
}
|
|
144
221
|
|
|
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;
|
|
222
|
+
// Structured deny for the Claude Code PreToolUse hook protocol. Never returns.
|
|
223
|
+
emitDeny(reason);
|
|
175
224
|
}
|
|
176
225
|
|
|
177
226
|
/**
|
|
@@ -193,6 +242,29 @@ async function isGitStashNonEmpty(projectDir) {
|
|
|
193
242
|
}
|
|
194
243
|
}
|
|
195
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Given a redirect token at `segment[i]`, return the index of the LAST token
|
|
247
|
+
* belonging to that redirect (the operand word, when one exists). Redirect
|
|
248
|
+
* operators and their operands name IO targets, not command arguments —
|
|
249
|
+
* `rm -rf /tmp/ok > out.log` must not collect `>` or `out.log` as rm targets
|
|
250
|
+
* (#983 FP fix). `dup` (`2>&1`) carries no operand word.
|
|
251
|
+
*
|
|
252
|
+
* @param {Array<{ text: string, quoted: boolean, redirect?: object }>} segment
|
|
253
|
+
* @param {number} i — index of the redirect token
|
|
254
|
+
* @returns {number}
|
|
255
|
+
*/
|
|
256
|
+
function redirectSpanEnd(segment, i) {
|
|
257
|
+
const tok = segment[i];
|
|
258
|
+
// `dup` (2>&1) carries its target inline; `heredoc` consumes its delimiter in
|
|
259
|
+
// the lexer (the body arrives as a separate QUOTED token) — neither owns an
|
|
260
|
+
// operand word, and claiming one would swallow the next real token.
|
|
261
|
+
const hasOperandWord = tok.redirect.mode !== 'dup' && tok.redirect.mode !== 'heredoc';
|
|
262
|
+
if (hasOperandWord && i + 1 < segment.length && !segment[i + 1].redirect) {
|
|
263
|
+
return i + 1;
|
|
264
|
+
}
|
|
265
|
+
return i;
|
|
266
|
+
}
|
|
267
|
+
|
|
196
268
|
/**
|
|
197
269
|
* Parse ALL non-flag path arguments from every `rm` invocation in a command.
|
|
198
270
|
*
|
|
@@ -201,72 +273,123 @@ async function isGitStashNonEmpty(projectDir) {
|
|
|
201
273
|
* chained commands (`rm -rf /tmp/x; rm -rf src/`). Quote-aware via
|
|
202
274
|
* tokenizeCommand so a path with spaces inside quotes is one target.
|
|
203
275
|
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
276
|
+
* Wrapper-aware (#982 T2): the segment verb resolves through the transparent
|
|
277
|
+
* wrappers in WRAPPER_UNWRAP via resolveSegmentVerb — `sudo rm -rf /tmp/ok`,
|
|
278
|
+
* `timeout 5 rm -rf /x`, `nohup rm -rf …` parse their REAL targets instead of
|
|
279
|
+
* falling back to the empty-target conservative block. Redirect tokens and
|
|
280
|
+
* their operands are skipped (#983): they are IO targets, not rm targets.
|
|
281
|
+
*
|
|
282
|
+
* Returns BOTH halves of the operand/redirect split (merge of the two
|
|
283
|
+
* 2026-08-03 guard sessions — the two protection layers are complementary):
|
|
284
|
+
* - `targets` — the rm operands (what gets deleted). Judged against the
|
|
285
|
+
* rule's `path-allowlist` by the caller.
|
|
286
|
+
* - `writeTargets` — the files each WRITE redirect (`truncate` or `append`
|
|
287
|
+
* mode) in the same invocation clobbers-or-creates. Read redirects,
|
|
288
|
+
* here-docs/here-strings and fd duplications (`2>&1`) contribute nothing.
|
|
289
|
+
* The caller holds these to the SAME allowlist (with `/dev/null` carved
|
|
290
|
+
* out via isNullSink), because `rm -rf /tmp/ok > src/important.ts` empties
|
|
291
|
+
* a project file on the strength of an allowlisted rm operand. This is the
|
|
292
|
+
* rm-context layer; protected artefacts (`> CLAUDE.md` — `> AGENTS.md` on
|
|
293
|
+
* Codex CLI, the same truncation class) are ADDITIONALLY
|
|
294
|
+
* denied command-independently by the `redirect-truncate-protected`
|
|
295
|
+
* policy rule (rule 14).
|
|
296
|
+
*
|
|
297
|
+
* The caller treats the rm-rf rule as "allowed" only when EVERY operand and
|
|
298
|
+
* EVERY write-redirect target passes its respective check — a segment whose
|
|
299
|
+
* pattern matched but whose verb does NOT resolve to `rm` (e.g. an interpreter
|
|
300
|
+
* payload) contributes no targets and stays fail-closed.
|
|
206
301
|
*
|
|
207
302
|
* @param {string} command
|
|
208
|
-
* @returns {string[]}
|
|
303
|
+
* @returns {{targets: string[], writeTargets: string[]}}
|
|
209
304
|
*/
|
|
210
305
|
function parseRmTargets(command) {
|
|
211
|
-
const tokens = tokenizeCommand(command);
|
|
212
306
|
const targets = [];
|
|
213
|
-
|
|
307
|
+
const writeTargets = [];
|
|
214
308
|
|
|
215
|
-
|
|
216
|
-
const verb =
|
|
217
|
-
|
|
218
|
-
if (isOperator) { i++; continue; }
|
|
219
|
-
if (verb !== 'rm') { i++; continue; }
|
|
309
|
+
for (const segment of blocker.splitChainSegments(blocker.tokenizeCommand(command))) {
|
|
310
|
+
const { verb, index } = blocker.resolveSegmentVerb(segment);
|
|
311
|
+
if (verb !== 'rm') continue;
|
|
220
312
|
|
|
221
|
-
// Consume this `rm` invocation's args until the next chain operator.
|
|
222
|
-
i++; // skip `rm`
|
|
223
313
|
let seenDashDash = false;
|
|
224
|
-
|
|
225
|
-
const tok =
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
314
|
+
for (let i = index + 1; i < segment.length; i++) {
|
|
315
|
+
const tok = segment[i];
|
|
316
|
+
if (tok.redirect) {
|
|
317
|
+
const end = redirectSpanEnd(segment, i);
|
|
318
|
+
const mode = tok.redirect.mode;
|
|
319
|
+
if ((mode === 'truncate' || mode === 'append') && end > i) {
|
|
320
|
+
const operand = segment[end];
|
|
321
|
+
if (operand && !(!operand.quoted && /^(;|&&|\|\||\||&)$/.test(operand.text))) {
|
|
322
|
+
writeTargets.push(operand.text);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
i = end;
|
|
232
326
|
continue;
|
|
233
327
|
}
|
|
328
|
+
if (!seenDashDash && tok.text === '--') { seenDashDash = true; continue; }
|
|
329
|
+
// A flag is unquoted and starts with '-' (and is not the bare '-' stdin marker).
|
|
330
|
+
if (!seenDashDash && !tok.quoted && tok.text.startsWith('-') && tok.text !== '-') continue;
|
|
234
331
|
targets.push(tok.text);
|
|
235
|
-
i++;
|
|
236
332
|
}
|
|
237
333
|
}
|
|
238
334
|
|
|
239
|
-
return targets;
|
|
335
|
+
return { targets, writeTargets };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* `/dev/null` is the one write-redirect destination that destroys nothing: it is
|
|
340
|
+
* a character device, so `>` on it neither truncates nor creates a file. It is
|
|
341
|
+
* also the single most common rm-plumbing target (`rm -rf /tmp/x 2> /dev/null`).
|
|
342
|
+
*
|
|
343
|
+
* Deliberately NOT folded into isRmPathAllowed: `rm -rf /dev/null` must stay
|
|
344
|
+
* blocked — deleting the device node is destructive, writing to it is not.
|
|
345
|
+
*
|
|
346
|
+
* @param {string} targetPath
|
|
347
|
+
* @returns {boolean}
|
|
348
|
+
*/
|
|
349
|
+
function isNullSink(targetPath) {
|
|
350
|
+
return Boolean(targetPath)
|
|
351
|
+
&& path.isAbsolute(targetPath)
|
|
352
|
+
&& path.normalize(targetPath) === path.join(path.sep, 'dev', 'null');
|
|
240
353
|
}
|
|
241
354
|
|
|
242
355
|
/**
|
|
243
356
|
* Detect whether the command contains an UNQUOTED `rm` invocation carrying BOTH
|
|
244
357
|
* 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
|
|
358
|
+
* including combined/short forms (`-rf`, `-fr`, `-r -f`) and the long-flag pair
|
|
359
|
+
* (`--recursive --force`). This catches flag-form variants the literal "rm -rf"
|
|
360
|
+
* pattern misses (#641 gap closure) while staying consistent with the
|
|
361
|
+
* quoted-payload guard: an `rm` that appears only inside a quoted token is NOT
|
|
362
|
+
* treated as an invocation here.
|
|
363
|
+
*
|
|
364
|
+
* Wrapper-aware (#982 T2) + redirect-skip (#983) — same segment mechanics as
|
|
365
|
+
* parseRmTargets above.
|
|
366
|
+
*
|
|
367
|
+
* Redirect operators and their targets are skipped for the same reason as in
|
|
368
|
+
* parseRmTargets: a redirect target is a filename the shell consumes, so
|
|
369
|
+
* `rm -r /tmp/x > -f` must not read `-f` as rm's force flag.
|
|
370
|
+
*
|
|
371
|
+
* This walker deliberately DISCARDS redirect spans entirely. Its question is
|
|
372
|
+
* only "does this command contain a recursive+force rm at all", i.e. whether
|
|
373
|
+
* the rm-rf rule MATCHES — it never decides allow-vs-deny. Write-redirect
|
|
374
|
+
* targets are judged once, by the `redirect-truncate-protected` policy rule
|
|
375
|
+
* (rule 14), never here — a second collector would give the guard a silently
|
|
376
|
+
* divergent opinion about the same fact.
|
|
249
377
|
*
|
|
250
378
|
* @param {string} command
|
|
251
379
|
* @returns {boolean}
|
|
252
380
|
*/
|
|
253
381
|
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`
|
|
382
|
+
for (const segment of blocker.splitChainSegments(blocker.tokenizeCommand(command))) {
|
|
383
|
+
const { verb, index } = blocker.resolveSegmentVerb(segment);
|
|
384
|
+
if (verb !== 'rm' || segment[index].quoted) continue;
|
|
385
|
+
|
|
263
386
|
let recursive = false;
|
|
264
387
|
let force = false;
|
|
265
388
|
let seenDashDash = false;
|
|
266
|
-
|
|
267
|
-
const t =
|
|
268
|
-
if (
|
|
269
|
-
if (!seenDashDash && t.text === '--') { seenDashDash = true;
|
|
389
|
+
for (let i = index + 1; i < segment.length; i++) {
|
|
390
|
+
const t = segment[i];
|
|
391
|
+
if (t.redirect) { i = redirectSpanEnd(segment, i); continue; }
|
|
392
|
+
if (!seenDashDash && t.text === '--') { seenDashDash = true; continue; }
|
|
270
393
|
if (!seenDashDash && !t.quoted && t.text.startsWith('-') && t.text !== '-') {
|
|
271
394
|
if (t.text === '--recursive') recursive = true;
|
|
272
395
|
else if (t.text === '--force') force = true;
|
|
@@ -275,16 +398,138 @@ function commandHasRecursiveForceRm(command) {
|
|
|
275
398
|
if (/[rR]/.test(t.text)) recursive = true;
|
|
276
399
|
if (/f/.test(t.text)) force = true;
|
|
277
400
|
}
|
|
278
|
-
i++;
|
|
279
|
-
continue;
|
|
280
401
|
}
|
|
281
|
-
|
|
402
|
+
// non-flag arg (a target) — skip
|
|
282
403
|
}
|
|
283
404
|
if (recursive && force) return true;
|
|
284
405
|
}
|
|
285
406
|
return false;
|
|
286
407
|
}
|
|
287
408
|
|
|
409
|
+
/** Probe operator per redirect mode — see findMatchedRedirectEntry. */
|
|
410
|
+
const REDIRECT_PROBE_OPS = { truncate: '>', append: '>>', read: '<' };
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Identify WHICH resolved redirect entry a matched redirect-truncate rule hit,
|
|
414
|
+
* so the deny reason can name the target (#983). redirectRuleMatches returns
|
|
415
|
+
* only a boolean and its glob matcher is internal to command-blocker.mjs —
|
|
416
|
+
* rather than duplicate the glob logic here (it would be the third copy), each
|
|
417
|
+
* candidate target is re-probed through redirectRuleMatches with a minimal
|
|
418
|
+
* single-redirect command. Resolved targets are guaranteed free of `$` and
|
|
419
|
+
* backticks (those are reported `unresolved`), so the quoted probe round-trips
|
|
420
|
+
* the target text exactly.
|
|
421
|
+
*
|
|
422
|
+
* `repoRoot` MUST be the same value the deciding redirectRuleMatches call used
|
|
423
|
+
* (#988 T1): an absolute/`~` target only matches when the root is supplied, so
|
|
424
|
+
* dropping it here would leave `hit === null` for exactly the targets the new
|
|
425
|
+
* resolution added and the deny reason would silently fall back to the pattern.
|
|
426
|
+
*
|
|
427
|
+
* @param {object} rule — the redirect-truncate policy rule
|
|
428
|
+
* @param {Array<{ target: string|null, mode: string, unresolved?: boolean }>} entries
|
|
429
|
+
* @param {string} repoRoot — absolute repo root, forwarded to redirectRuleMatches
|
|
430
|
+
* @returns {{ target: string, mode: string }|null}
|
|
431
|
+
*/
|
|
432
|
+
function findMatchedRedirectEntry(rule, entries, repoRoot) {
|
|
433
|
+
for (const entry of entries) {
|
|
434
|
+
if (entry.unresolved) continue;
|
|
435
|
+
const op = REDIRECT_PROBE_OPS[entry.mode] ?? '>';
|
|
436
|
+
const probe = `${op} "${entry.target.replace(/[\\"]/g, '\\$&')}"`;
|
|
437
|
+
if (blocker.redirectRuleMatches(rule, probe, { repoRoot })) return entry;
|
|
438
|
+
}
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Expand a LEADING `$TMPDIR` / `${TMPDIR}` reference in an rm target token.
|
|
444
|
+
*
|
|
445
|
+
* The hook is a PreToolUse gate: it sees the raw, UNEXPANDED shell string.
|
|
446
|
+
* `rm -rf "${TMPDIR}"scratch` therefore arrives as the token `${TMPDIR}scratch`,
|
|
447
|
+
* which `path.isAbsolute()` reads as a RELATIVE path — so the #641 `wasAbsolute`
|
|
448
|
+
* gate (correctly, on its own terms) refuses to match it against any /tmp-class
|
|
449
|
+
* prefix, and the path is instead resolved against the PROJECT dir. A legitimate
|
|
450
|
+
* temp cleanup is blocked (#935 cause 1). Substituting the value the shell would
|
|
451
|
+
* have substituted, BEFORE absoluteness is judged, is the narrow fix.
|
|
452
|
+
*
|
|
453
|
+
* This does NOT by itself widen the safe set: the expansion result still has to
|
|
454
|
+
* land under a CONFINED allowlist prefix (see resolveAllowlistPrefixes), so an
|
|
455
|
+
* inherited `TMPDIR=/etc` expands to `/etc/...` and is still blocked (#642).
|
|
456
|
+
*
|
|
457
|
+
* Deliberately narrow: only TMPDIR, only in leading position, only when its
|
|
458
|
+
* value is absolute, and byte-for-byte as the shell would splice it (no invented
|
|
459
|
+
* separator — `TMPDIR=/tmp` + `${TMPDIR}x` really is `/tmpx`, not `/tmp/x`).
|
|
460
|
+
* A generic env-expander would be a far larger attack surface.
|
|
461
|
+
*
|
|
462
|
+
* @param {string} targetPath
|
|
463
|
+
* @returns {string}
|
|
464
|
+
*/
|
|
465
|
+
function expandTmpdirToken(targetPath) {
|
|
466
|
+
const m = /^\$(?:TMPDIR\b|\{TMPDIR\})/.exec(targetPath);
|
|
467
|
+
if (!m) return targetPath;
|
|
468
|
+
const value = process.env.TMPDIR || os.tmpdir();
|
|
469
|
+
if (!value || !path.isAbsolute(value)) return targetPath;
|
|
470
|
+
return value + targetPath.slice(m[0].length);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Canonicalise `absPath` by realpath-ing the deepest EXISTING ancestor and
|
|
475
|
+
* re-attaching the non-existent suffix.
|
|
476
|
+
*
|
|
477
|
+
* `fs.realpathSync.native` throws ENOENT on a path that does not exist — which
|
|
478
|
+
* is the NORMAL case here: `rm -rf` frequently targets something already gone,
|
|
479
|
+
* and a policy prefix (`$TMPDIR` of another boot session) need not exist either.
|
|
480
|
+
* The upward walk mirrors the identical pattern in hooks/enforce-scope.mjs
|
|
481
|
+
* (SECURITY-REQ-03) rather than inventing a second one.
|
|
482
|
+
*
|
|
483
|
+
* Any other fs error → return the lexical input unchanged. That is the fail-safe
|
|
484
|
+
* direction: the caller then compares lexically, i.e. exactly the pre-#935
|
|
485
|
+
* behaviour, never a broader one.
|
|
486
|
+
*
|
|
487
|
+
* @param {string} absPath
|
|
488
|
+
* @returns {string}
|
|
489
|
+
*/
|
|
490
|
+
function canonicalizeAncestors(absPath) {
|
|
491
|
+
let ancestor = absPath;
|
|
492
|
+
const segments = [];
|
|
493
|
+
for (;;) {
|
|
494
|
+
try {
|
|
495
|
+
const real = fs.realpathSync.native(ancestor);
|
|
496
|
+
return segments.length ? path.join(real, ...segments.reverse()) : real;
|
|
497
|
+
} catch (err) {
|
|
498
|
+
// ENOENT: nothing at this level yet. ENOTDIR: a FILE sits in the chain.
|
|
499
|
+
// Both mean "keep walking up"; anything else (ELOOP, EACCES) → lexical.
|
|
500
|
+
if (err?.code !== 'ENOENT' && err?.code !== 'ENOTDIR') return absPath;
|
|
501
|
+
const parent = path.dirname(ancestor);
|
|
502
|
+
if (parent === ancestor) return absPath; // reached the fs root, nothing existed
|
|
503
|
+
segments.push(path.basename(ancestor));
|
|
504
|
+
ancestor = parent;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Canonical LOCATION of an `rm` target.
|
|
511
|
+
*
|
|
512
|
+
* `rm -rf X` removes X ITSELF: when X is a symlink the link is unlinked and what
|
|
513
|
+
* it points at is untouched. So the final segment must NOT be symlink-resolved —
|
|
514
|
+
* only its parent chain. Resolving the leaf would be both wrong about what rm
|
|
515
|
+
* does and unsafe: `<project>/link-to-tmp` would canonicalise to /private/tmp and
|
|
516
|
+
* read as a safe temp target while the delete lands on project content.
|
|
517
|
+
*
|
|
518
|
+
* Canonicalising the parent chain keeps the judgement honest in both directions:
|
|
519
|
+
* - `/tmp/link-to-etc/x` canonicalises OUT of the temp allowlist → blocked.
|
|
520
|
+
* The pre-#935 literal prefix match allowed it (measured, real hole).
|
|
521
|
+
* - `$TMPDIR/x` and its `/private/var/folders/...` canonical spelling collapse
|
|
522
|
+
* onto the same string → both allowed, no literal-form guessing.
|
|
523
|
+
*
|
|
524
|
+
* @param {string} abs — already `path.normalize`d absolute path
|
|
525
|
+
* @returns {string}
|
|
526
|
+
*/
|
|
527
|
+
function canonicalizeRmTarget(abs) {
|
|
528
|
+
const parent = path.dirname(abs);
|
|
529
|
+
if (parent === abs) return abs; // filesystem root
|
|
530
|
+
return path.join(canonicalizeAncestors(parent), path.basename(abs));
|
|
531
|
+
}
|
|
532
|
+
|
|
288
533
|
/**
|
|
289
534
|
* Return true when a single path is a safe `rm -rf` target.
|
|
290
535
|
*
|
|
@@ -293,7 +538,7 @@ function commandHasRecursiveForceRm(command) {
|
|
|
293
538
|
* - <projectRoot>/node_modules (any depth)
|
|
294
539
|
* - /tmp/ (any depth) — agent-owned scratch
|
|
295
540
|
* - /private/tmp/ (any depth) — macOS canonical /tmp
|
|
296
|
-
* - resolved os.tmpdir() / $TMPDIR (any depth)
|
|
541
|
+
* - resolved os.tmpdir() / $TMPDIR (any depth), in either spelling
|
|
297
542
|
*
|
|
298
543
|
* The /tmp-class prefixes come from the rule's optional `path-allowlist` and are
|
|
299
544
|
* resolved at runtime here.
|
|
@@ -307,12 +552,17 @@ function isRmPathAllowed(targetPath, projectDir, ruleAllowlist = []) {
|
|
|
307
552
|
if (!targetPath) return false;
|
|
308
553
|
|
|
309
554
|
const base = projectDir || process.cwd();
|
|
310
|
-
|
|
555
|
+
// Restore the shell's own substitution before judging absoluteness (#935).
|
|
556
|
+
const effective = expandTmpdirToken(targetPath);
|
|
557
|
+
const wasAbsolute = path.isAbsolute(effective);
|
|
311
558
|
|
|
312
559
|
// Project-relative safe dirs (always allowed, independent of the rule allowlist).
|
|
560
|
+
// Deliberately LEXICAL: a project-relative candidate is never symlink-resolved,
|
|
561
|
+
// otherwise a link inside the project pointing at /tmp would read as safe and
|
|
562
|
+
// `rm -rf <that link>` — which destroys project content — would be allowed.
|
|
313
563
|
const abs = wasAbsolute
|
|
314
|
-
? path.normalize(
|
|
315
|
-
: path.resolve(base,
|
|
564
|
+
? path.normalize(effective)
|
|
565
|
+
: path.resolve(base, effective);
|
|
316
566
|
|
|
317
567
|
const safeProjectDirs = [
|
|
318
568
|
path.join(base, '.orchestrator', 'tmp'),
|
|
@@ -328,9 +578,14 @@ function isRmPathAllowed(targetPath, projectDir, ruleAllowlist = []) {
|
|
|
328
578
|
// /tmp prefix just because the project dir itself happens to live under /tmp
|
|
329
579
|
// (the case on CI runners where os.tmpdir() === /tmp). #641.
|
|
330
580
|
if (wasAbsolute) {
|
|
581
|
+
// Compare CANONICAL forms on both sides. Literal matching made the verdict
|
|
582
|
+
// depend on which spelling of the same directory was typed (`/var/folders/…`
|
|
583
|
+
// allowed, `/private/var/folders/…` blocked — #935 cause 2) and let a symlink
|
|
584
|
+
// under /tmp launder a non-temp destination into the allowlist.
|
|
585
|
+
const canonTarget = canonicalizeRmTarget(abs);
|
|
331
586
|
for (const prefix of resolveAllowlistPrefixes(ruleAllowlist)) {
|
|
332
587
|
// The target must be the prefix dir itself or a descendant of it.
|
|
333
|
-
if (
|
|
588
|
+
if (canonTarget === prefix || canonTarget.startsWith(prefix + path.sep)) return true;
|
|
334
589
|
}
|
|
335
590
|
}
|
|
336
591
|
|
|
@@ -342,23 +597,51 @@ function isRmPathAllowed(targetPath, projectDir, ruleAllowlist = []) {
|
|
|
342
597
|
* prefixes. `$TMPDIR` expands to env.TMPDIR (if set) and os.tmpdir(); literal
|
|
343
598
|
* paths are normalised. Trailing slashes are stripped for prefix comparison.
|
|
344
599
|
*
|
|
600
|
+
* Every prefix is emitted in BOTH its lexical and its canonical (realpath)
|
|
601
|
+
* spelling, because the target it is compared against is canonicalised too
|
|
602
|
+
* (see canonicalizeRmTarget) — and when realpath is unavailable both sides fall
|
|
603
|
+
* back to lexical together, so the pair never drifts apart.
|
|
604
|
+
*
|
|
345
605
|
* @param {string[]} ruleAllowlist
|
|
346
606
|
* @returns {string[]} normalised absolute prefixes (no trailing slash)
|
|
347
607
|
*/
|
|
348
608
|
function resolveAllowlistPrefixes(ruleAllowlist) {
|
|
349
609
|
const out = new Set();
|
|
610
|
+
const strip = (p) => path.normalize(p).replace(/[/\\]+$/, '');
|
|
611
|
+
|
|
612
|
+
// Canonical macOS spellings are listed EXPLICITLY next to their symlink form:
|
|
613
|
+
// /tmp → /private/tmp and /var/folders → /private/var/folders. A TMPDIR handed
|
|
614
|
+
// to us already canonicalised (what fs.realpath returns on macOS) previously
|
|
615
|
+
// failed this confinement check and contributed NO prefix at all, so EVERY
|
|
616
|
+
// $TMPDIR target was blocked (#935 cause 2).
|
|
617
|
+
const tempRoots = ['/tmp', '/private/tmp', '/var/folders', '/private/var/folders']
|
|
618
|
+
.map((p) => path.normalize(p));
|
|
619
|
+
const underTempRoot = (p) =>
|
|
620
|
+
tempRoots.some((root) => p === root || p.startsWith(root + path.sep));
|
|
621
|
+
|
|
622
|
+
// Operator-authored, VCS-reviewed policy literals (`/tmp/`, `/private/tmp/`).
|
|
350
623
|
const add = (p) => {
|
|
351
624
|
if (!p) return;
|
|
352
|
-
const
|
|
353
|
-
if (
|
|
625
|
+
const lex = strip(p);
|
|
626
|
+
if (!lex) return;
|
|
627
|
+
out.add(lex);
|
|
628
|
+
const canon = strip(canonicalizeAncestors(lex));
|
|
629
|
+
if (canon) out.add(canon);
|
|
354
630
|
};
|
|
355
|
-
|
|
631
|
+
|
|
632
|
+
// Environment-derived (`$TMPDIR`) — attacker-influencable, hence DOUBLE
|
|
633
|
+
// confinement: the value must sit under a temp root both as written AND after
|
|
634
|
+
// symlink resolution. Checking only the lexical form would let a `TMPDIR`
|
|
635
|
+
// pointing at a symlink under /tmp (e.g. /tmp/evil → <project>) promote the
|
|
636
|
+
// project itself to an allowlisted prefix (#642 confinement, extended).
|
|
356
637
|
const addTemp = (p) => {
|
|
357
638
|
if (!p || !path.isAbsolute(p)) return;
|
|
358
|
-
const
|
|
359
|
-
if (
|
|
360
|
-
|
|
361
|
-
|
|
639
|
+
const lex = strip(p);
|
|
640
|
+
if (!lex || !underTempRoot(lex)) return;
|
|
641
|
+
const canon = strip(canonicalizeAncestors(lex));
|
|
642
|
+
if (!canon || !underTempRoot(canon)) return;
|
|
643
|
+
out.add(lex);
|
|
644
|
+
out.add(canon);
|
|
362
645
|
};
|
|
363
646
|
|
|
364
647
|
for (const entry of Array.isArray(ruleAllowlist) ? ruleAllowlist : []) {
|
|
@@ -402,7 +685,10 @@ async function main() {
|
|
|
402
685
|
const lines = mdContent.split(/\r?\n/);
|
|
403
686
|
let inConfig = false;
|
|
404
687
|
for (const line of lines) {
|
|
405
|
-
|
|
688
|
+
// SSOT predicate (#968) — never re-derive this comparison. A local copy
|
|
689
|
+
// that drifts LOOSER than the extractor silently disagrees with the
|
|
690
|
+
// runtime about where the config block starts.
|
|
691
|
+
if (isSessionConfigHeading(line)) { inConfig = true; continue; }
|
|
406
692
|
if (inConfig && /^## /.test(line)) break;
|
|
407
693
|
if (inConfig) {
|
|
408
694
|
const m = line.match(/^\s*(?:-\s+\*\*)?allow-destructive-ops(?::\*\*)?\s*:\s*(\S+)/);
|
|
@@ -416,42 +702,75 @@ async function main() {
|
|
|
416
702
|
// No config file or parse error — proceed to policy check
|
|
417
703
|
}
|
|
418
704
|
|
|
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();
|
|
705
|
+
// G4 — policy load (#972: floor/overlay merge, not first-hit-wins).
|
|
706
|
+
// The plugin-root policy is the FLOOR; a cwd/projectDir policy is an OVERLAY
|
|
707
|
+
// that can only add rules or escalate severity — an empty or malformed
|
|
708
|
+
// consumer policy fails TO THE FLOOR instead of silently disarming the guard.
|
|
709
|
+
// Only when NO usable policy exists at all does the guard keep its documented
|
|
710
|
+
// fail-open (rules === null → emitAllow with a stderr warning).
|
|
711
|
+
const { rules, warnings } = await loadEffectivePolicy({
|
|
712
|
+
cwd: process.cwd(),
|
|
713
|
+
projectDir,
|
|
714
|
+
pluginRoot: resolvePluginRoot(),
|
|
715
|
+
cache: _policyCache,
|
|
716
|
+
});
|
|
717
|
+
for (const warning of warnings) {
|
|
718
|
+
process.stderr.write(`⚠ pre-bash-destructive-guard: ${warning}\n`);
|
|
444
719
|
}
|
|
720
|
+
if (!Array.isArray(rules)) return emitAllow();
|
|
445
721
|
|
|
446
722
|
// G5 — rule evaluation
|
|
447
|
-
for (const rule of
|
|
723
|
+
for (const rule of rules) {
|
|
448
724
|
const { id, pattern, severity, rationale = '' } = rule;
|
|
449
725
|
|
|
726
|
+
// #983 — redirect-truncate rules are decided by redirect TARGET via
|
|
727
|
+
// redirectRuleMatches, NEVER by the generic pattern path below: their
|
|
728
|
+
// `pattern: ">"` substring-matches virtually every redirect (measured
|
|
729
|
+
// interim FP: `bash -c 'echo a > b'` denied), so this branch must fully
|
|
730
|
+
// shadow the pattern match for this rule class.
|
|
731
|
+
if (rule.type === 'redirect-truncate') {
|
|
732
|
+
const modes = new Set(
|
|
733
|
+
Array.isArray(rule.modes) && rule.modes.length > 0 ? rule.modes : ['truncate']
|
|
734
|
+
);
|
|
735
|
+
const collected = blocker.extractRedirectTargets(command);
|
|
736
|
+
const entries = collected.filter((e) => modes.has(e.mode));
|
|
737
|
+
// Recursion-cap markers (#988 T2) carry `mode: null` by construction — a
|
|
738
|
+
// bare `modes.has(e.mode)` filter drops them, which would make the new
|
|
739
|
+
// marker unobservable here. Keep them alongside the mode-matching ones.
|
|
740
|
+
const unresolved = collected.filter(
|
|
741
|
+
(e) => e.unresolved && (e.mode === null || modes.has(e.mode))
|
|
742
|
+
);
|
|
743
|
+
if (unresolved.length > 0) {
|
|
744
|
+
// Variable/substitution operands are never match candidates (#641 FP
|
|
745
|
+
// class) — surface them instead of guessing; never block on a guess.
|
|
746
|
+
// Same for a payload subtree a recursion cap cut off: the cap stays,
|
|
747
|
+
// its effect stops being silent.
|
|
748
|
+
const reasons = [
|
|
749
|
+
...new Set(unresolved.map((e) => e.reason ?? 'variable/substitution')),
|
|
750
|
+
].join(', ');
|
|
751
|
+
process.stderr.write(
|
|
752
|
+
`⚠ pre-bash-destructive-guard: unresolved redirect target (${reasons}) — not matched (fail-visible)\n`
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
if (!blocker.redirectRuleMatches(rule, command, { repoRoot: projectDir })) continue;
|
|
756
|
+
if (severity !== 'block') {
|
|
757
|
+
process.stderr.write(
|
|
758
|
+
`⚠ pre-bash-destructive-guard: redirect target matched (rule: ${id}) — ${rationale}\n`
|
|
759
|
+
);
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
// Reason stays short (stdout-budget): operator + target, never the command.
|
|
763
|
+
const hit = findMatchedRedirectEntry(rule, entries, projectDir);
|
|
764
|
+
const label = hit ? `${REDIRECT_PROBE_OPS[hit.mode] ?? '>'} ${hit.target}` : pattern;
|
|
765
|
+
await blockCommand(label, id, rationale, command, sessionId);
|
|
766
|
+
continue; // unreachable (blockCommand never returns) — kept for clarity
|
|
767
|
+
}
|
|
768
|
+
|
|
450
769
|
// The rm-rf-destructive rule also fires for recursive+force rm flag variants
|
|
451
770
|
// the literal "rm -rf" pattern misses (`rm -r -f`, `rm -fr`) — #641 gap closure.
|
|
452
771
|
const matched = id === 'rm-rf-destructive'
|
|
453
|
-
? (commandMatchesBlocked(command, pattern) || commandHasRecursiveForceRm(command))
|
|
454
|
-
: commandMatchesBlocked(command, pattern);
|
|
772
|
+
? (blocker.commandMatchesBlocked(command, pattern) || commandHasRecursiveForceRm(command))
|
|
773
|
+
: blocker.commandMatchesBlocked(command, pattern);
|
|
455
774
|
if (!matched) continue;
|
|
456
775
|
|
|
457
776
|
if (severity === 'warn') {
|
|
@@ -484,14 +803,25 @@ async function main() {
|
|
|
484
803
|
// Special: rm-rf-destructive — path exception
|
|
485
804
|
if (id === 'rm-rf-destructive') {
|
|
486
805
|
const ruleAllowlist = Array.isArray(rule['path-allowlist']) ? rule['path-allowlist'] : [];
|
|
487
|
-
const targets = parseRmTargets(command);
|
|
806
|
+
const { targets, writeTargets } = parseRmTargets(command);
|
|
488
807
|
// Allow ONLY when there is at least one target AND every target is
|
|
489
808
|
// allowlisted. An unparseable command (no targets) or any non-allowlisted
|
|
490
809
|
// target → block (conservative). This makes mixed chains like
|
|
491
810
|
// `rm -rf /tmp/x; rm -rf src/` block on the src/ target.
|
|
811
|
+
//
|
|
812
|
+
// Every WRITE-redirect target of the same invocation must clear the SAME
|
|
813
|
+
// allowlist (with /dev/null carved out): `>` truncates its target before
|
|
814
|
+
// rm ever runs, so without this the allow path waved through
|
|
815
|
+
// `rm -rf /tmp/ok > src/important.ts` on the strength of an allowlisted
|
|
816
|
+
// rm operand. Protected artefacts (`> CLAUDE.md`) are ADDITIONALLY
|
|
817
|
+
// covered command-independently by rule 14 (redirect-truncate-protected)
|
|
818
|
+
// — two complementary layers from the two 2026-08-03 guard sessions.
|
|
492
819
|
const allAllowed =
|
|
493
820
|
targets.length > 0 &&
|
|
494
|
-
targets.every((t) => isRmPathAllowed(t, projectDir, ruleAllowlist))
|
|
821
|
+
targets.every((t) => isRmPathAllowed(t, projectDir, ruleAllowlist)) &&
|
|
822
|
+
writeTargets.every(
|
|
823
|
+
(t) => isNullSink(t) || isRmPathAllowed(t, projectDir, ruleAllowlist)
|
|
824
|
+
);
|
|
495
825
|
if (allAllowed) {
|
|
496
826
|
// Safe paths only (.orchestrator/tmp, node_modules, /tmp, $TMPDIR) — allow
|
|
497
827
|
continue;
|
|
@@ -506,6 +836,41 @@ async function main() {
|
|
|
506
836
|
return emitAllow();
|
|
507
837
|
}
|
|
508
838
|
|
|
839
|
+
// ---------------------------------------------------------------------------
|
|
840
|
+
// Entry point (#992)
|
|
841
|
+
//
|
|
842
|
+
// TWO distinct failure classes, two distinct banners — do not merge them:
|
|
843
|
+
//
|
|
844
|
+
// 1. LOAD failure (`bootstrap()` throws): the guard never armed. Nothing was
|
|
845
|
+
// evaluated, so the fail-open is total. This used to be a bare exit-1
|
|
846
|
+
// crash with 0 bytes of stdout — indistinguishable from an allow, and
|
|
847
|
+
// therefore invisible. Now it exits 0 (still fail-open, so a broken module
|
|
848
|
+
// cannot brick the session) but SAYS SO, loudly, once per session.
|
|
849
|
+
// 2. RUNTIME failure inside `main()`: pre-existing behaviour, unchanged.
|
|
850
|
+
// The guard armed and then tripped over a specific command; that is a
|
|
851
|
+
// narrower blast radius and keeps its historical `internal error` line.
|
|
852
|
+
// ---------------------------------------------------------------------------
|
|
853
|
+
try {
|
|
854
|
+
await bootstrap();
|
|
855
|
+
} catch (loadError) {
|
|
856
|
+
try {
|
|
857
|
+
const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
|
|
858
|
+
// Unthrottled by design: EVERY call in an unarmed session says so (#992
|
|
859
|
+
// hardening — the once-per-session marker it used to pass through was
|
|
860
|
+
// suppressible by a bare `touch` on a derivable tmp path).
|
|
861
|
+
emitGuardInactiveBanner({ error: loadError });
|
|
862
|
+
} catch {
|
|
863
|
+
// Last resort: even the banner helper failed to load. Emit unconditionally
|
|
864
|
+
// (no once-per-session keying) — repeated noise beats a silent disarm.
|
|
865
|
+
process.stderr.write(
|
|
866
|
+
'🚨 pre-bash-destructive-guard: GUARD INACTIVE — module load failed ' +
|
|
867
|
+
`(${String(loadError?.message || loadError).split('\n')[0]}). ` +
|
|
868
|
+
'Destructive Bash commands are NOT being blocked. See issue #992.\n'
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
process.exit(0); // fail-open, but no longer fail-silent
|
|
872
|
+
}
|
|
873
|
+
|
|
509
874
|
// Top-level error handler — never let exit 1 leak
|
|
510
875
|
main().catch((e) => {
|
|
511
876
|
process.stderr.write(
|