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
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* guard-source-loader.mjs — degradation-VISIBLE module loading for deny-capable hooks.
|
|
3
|
+
*
|
|
4
|
+
* ## The defect this closes (#992)
|
|
5
|
+
*
|
|
6
|
+
* `hooks/hooks.json` runs `sh run-node.sh <hook>.mjs`, which `exec node "$@"`.
|
|
7
|
+
* A deny-capable hook that STATICALLY imports a repo module inherits that
|
|
8
|
+
* module's parse failure at ESM LINK time — before the first statement of the
|
|
9
|
+
* hook body runs. Consequences, all measured:
|
|
10
|
+
*
|
|
11
|
+
* - the hook's own `main().catch(...)` handler is structurally unreachable
|
|
12
|
+
* (it only covers runtime errors inside `main()`),
|
|
13
|
+
* - node terminates with EXIT 1, **stdout 0 bytes**, stderr = a SyntaxError
|
|
14
|
+
* stack trace.
|
|
15
|
+
*
|
|
16
|
+
* Under the exit-0 PreToolUse protocol (#906) the exit code no longer carries a
|
|
17
|
+
* decision: ALLOW is `exit 0` + empty stdout, DENY is `exit 0` + exactly one
|
|
18
|
+
* `hookSpecificOutput` line. A crashed hook produces 0 bytes on stdout and is
|
|
19
|
+
* therefore, ON THE ONLY DECISION-BEARING CHANNEL, indistinguishable from an
|
|
20
|
+
* explicit `emitAllow()`. `git reset --hard` runs through. One broken
|
|
21
|
+
* `scripts/lib/command-blocker.mjs` silently disarms 4 of the 7 deny-capable
|
|
22
|
+
* hooks at once (destructive-guard, enforce-scope, enforce-commands,
|
|
23
|
+
* sessions-ledger-guard) — Bash *and* Edit/Write enforcement.
|
|
24
|
+
*
|
|
25
|
+
* ## The two parts, and why the banner is the base
|
|
26
|
+
*
|
|
27
|
+
* 1. **Loud once-per-session banner on EVERY load failure.** The damage was not
|
|
28
|
+
* "no fallback", it was an *invisible* outage: an ambiguous harness error
|
|
29
|
+
* line that wave agents read as a crash rather than a policy block, and
|
|
30
|
+
* began routing around. The banner therefore names the CONSEQUENCE ("guard
|
|
31
|
+
* INACTIVE" / "running against HEAD, not your working tree"), not just a
|
|
32
|
+
* file path.
|
|
33
|
+
* 2. **`git show HEAD:<path>` fallback — for dependency-free modules ONLY.** A
|
|
34
|
+
* module whose every import is a `node:*` builtin is `data:`-URL loadable, so
|
|
35
|
+
* its committed source can be re-imported from HEAD. Deliberately NOT
|
|
36
|
+
* generalised to modules with RELATIVE imports, which a `data:` URL cannot
|
|
37
|
+
* resolve — that would need its own recursive resolver. Opt-in per module via
|
|
38
|
+
* `headFallback: true`, gated by {@link HEAD_FALLBACK_ALLOWLIST}; for every
|
|
39
|
+
* other module part 1 (banner) stands alone.
|
|
40
|
+
*
|
|
41
|
+
* ## Generalised across all four deny-capable hooks (#993)
|
|
42
|
+
*
|
|
43
|
+
* This started life hard-wired to `pre-bash-destructive-guard` +
|
|
44
|
+
* `command-blocker.mjs` (the banner text, the required-export list, and the
|
|
45
|
+
* repo-relative path were all module constants). {@link armGuard} lifts every
|
|
46
|
+
* one of those into a PARAMETER — `hookName`, per-spec `requires`, per-spec
|
|
47
|
+
* `headFallback`, and the `consequence` prose — so `enforce-scope`,
|
|
48
|
+
* `enforce-commands` and `sessions-ledger-guard` bind their own repo
|
|
49
|
+
* dependencies through the same loader without re-freezing this hook's name into
|
|
50
|
+
* a banner literal. `hookName` is MANDATORY on both public exports: a default
|
|
51
|
+
* would silently reintroduce exactly the drift #993 removes.
|
|
52
|
+
*
|
|
53
|
+
* **The coupling is mandatory: the fallback must never fire silently.** A
|
|
54
|
+
* successful HEAD fallback banners too ("running against HEAD, not the working
|
|
55
|
+
* tree"), otherwise a visible hole is traded for an invisible semantic drift.
|
|
56
|
+
*
|
|
57
|
+
* **The HEAD fallback presupposes a git checkout.** It shells out to
|
|
58
|
+
* `git show HEAD:<path>` in the PLUGIN ROOT (not the project dir). For an
|
|
59
|
+
* npm-installed plugin — or any tarball/vendored copy without a `.git` — that
|
|
60
|
+
* command fails by construction, so half of this module is structurally
|
|
61
|
+
* unavailable there: EVERY load failure degrades straight to the GUARD INACTIVE
|
|
62
|
+
* banner below. That is the designed fail-loud direction, not a regression, but
|
|
63
|
+
* it means the banner is the ONLY protection npm consumers get.
|
|
64
|
+
*
|
|
65
|
+
* ## Why the marker is not a mute switch (post-#992 hardening)
|
|
66
|
+
*
|
|
67
|
+
* The once-per-session throttle used to key on the mere EXISTENCE of a
|
|
68
|
+
* predictably-named tmp file, and it gated BOTH banner classes. Measured: an
|
|
69
|
+
* agent could `touch` that path (not a destructive command), then append a
|
|
70
|
+
* syntax error to a `scripts/lib/` module (not on any redirect denylist) and the
|
|
71
|
+
* total-outage banner went silent while the decision channel said ALLOW —
|
|
72
|
+
* strictly worse than the pre-#992 crash, which at least printed a stack trace.
|
|
73
|
+
* Two changes close it:
|
|
74
|
+
*
|
|
75
|
+
* - **The `inactive` (total-outage) banner is NOT throttled at all.** Once per
|
|
76
|
+
* call, every call. Throttling is for the *degraded* case (guard armed,
|
|
77
|
+
* evaluating HEAD); a guard that is OFF must keep saying so. Noise there is
|
|
78
|
+
* strictly cheaper than a silent disarm.
|
|
79
|
+
* - **Where the marker remains (`head-fallback`), CONTENT decides, not
|
|
80
|
+
* existence**: a scoped payload (magic + kind + banner key + projectDir
|
|
81
|
+
* digest + boot epoch) that an empty `touch` cannot forge, opened
|
|
82
|
+
* `O_CREAT|O_EXCL|O_NOFOLLOW` for write and `O_NOFOLLOW` for read — so the
|
|
83
|
+
* marker path can no longer be aimed at an arbitrary file via a symlink,
|
|
84
|
+
* and a foreign or stale file makes the banner REPEAT rather than vanish.
|
|
85
|
+
*
|
|
86
|
+
* Honest residual: an attacker who runs as the same uid and reproduces the
|
|
87
|
+
* payload format can still suppress the `head-fallback` banner. That is why the
|
|
88
|
+
* class that actually means "unprotected" no longer depends on the marker.
|
|
89
|
+
*
|
|
90
|
+
* Measured cost: `git show` median 4.2 ms (n=21) against a hook allow-path
|
|
91
|
+
* median of 61 ms (n=15) — +11 ms, and only in the defect case. Zero in normal
|
|
92
|
+
* operation: nothing here runs unless an import already threw.
|
|
93
|
+
*
|
|
94
|
+
* ## Hard constraint on this file
|
|
95
|
+
*
|
|
96
|
+
* Everything below runs on the error path of a module-loading failure, so it
|
|
97
|
+
* MUST NOT import any repo module that could itself be the broken one —
|
|
98
|
+
* `node:*` builtins only. Keep it that way. An on-disk source cache under
|
|
99
|
+
* `.orchestrator/runtime/` was considered and REJECTED: its cold-start failure
|
|
100
|
+
* mode is exactly the target scenario (a fresh worktree mid-merge), and it
|
|
101
|
+
* creates a deletable trust anchor inside the writable repo.
|
|
102
|
+
*
|
|
103
|
+
* Known, accepted gap: a COMMITTED conflict marker breaks the HEAD copy too —
|
|
104
|
+
* then only the banner fires. That is why the banner is the base and the
|
|
105
|
+
* fallback the topping.
|
|
106
|
+
*
|
|
107
|
+
* Issue: #992.
|
|
108
|
+
*/
|
|
109
|
+
|
|
110
|
+
import fs from 'node:fs';
|
|
111
|
+
import os from 'node:os';
|
|
112
|
+
import path from 'node:path';
|
|
113
|
+
import crypto from 'node:crypto';
|
|
114
|
+
import { fileURLToPath } from 'node:url';
|
|
115
|
+
import { execFileSync } from 'node:child_process';
|
|
116
|
+
|
|
117
|
+
/** TTL for the session-id-less marker fallback, mirroring `run-node.sh` (6h). */
|
|
118
|
+
const BANNER_TTL_MS = 6 * 60 * 60 * 1000;
|
|
119
|
+
|
|
120
|
+
/** Marker payload discriminator — an empty `touch` matches none of it. */
|
|
121
|
+
const MARKER_MAGIC = 'session-orchestrator/guard-banner';
|
|
122
|
+
const MARKER_VERSION = 1;
|
|
123
|
+
|
|
124
|
+
/** Bucket width for the boot-epoch field (seconds), and its accepted drift. */
|
|
125
|
+
const BOOT_BUCKET_S = 10;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The ONLY module basenames a `git show HEAD:` fallback is sound for.
|
|
129
|
+
*
|
|
130
|
+
* The fallback re-imports committed source through a `data:` URL (see
|
|
131
|
+
* {@link importFromSource}), and a `data:` URL has NO base against which a
|
|
132
|
+
* RELATIVE import specifier could resolve. So the fallback is correct only for a
|
|
133
|
+
* module whose every import is a `node:*` builtin (or which imports nothing at
|
|
134
|
+
* all). This set is that dependency-free allowlist; a `headFallback: true` on a
|
|
135
|
+
* module NOT in it (e.g. `hardening.mjs` / `platform.mjs`, which carry relative
|
|
136
|
+
* imports) would silently produce an unloadable `data:` module — so
|
|
137
|
+
* {@link armGuard} rejects it as a hard CONFIG error rather than arming a guard
|
|
138
|
+
* whose fallback can never fire.
|
|
139
|
+
*
|
|
140
|
+
* Keyed on BASENAME deliberately: it is the `git show HEAD:<relPath>` leaf, and
|
|
141
|
+
* a dependency-free file keeps that property wherever in the tree it sits. To
|
|
142
|
+
* add a module, verify its import list is `node:*`-only first.
|
|
143
|
+
*/
|
|
144
|
+
const HEAD_FALLBACK_ALLOWLIST = new Set([
|
|
145
|
+
'command-blocker.mjs',
|
|
146
|
+
'io.mjs',
|
|
147
|
+
'path-utils.mjs',
|
|
148
|
+
'common.mjs',
|
|
149
|
+
'plugin-root.mjs',
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The ONLY environment keys `git show HEAD:<path>` may inherit (see
|
|
154
|
+
* {@link readFromHead}).
|
|
155
|
+
*
|
|
156
|
+
* An ALLOWLIST, deliberately — the predecessor was a denylist that named the
|
|
157
|
+
* git repository-/object-/config-discovery vars one by one, and a denylist is
|
|
158
|
+
* unsound by construction: it silently re-opens on every git release that adds a
|
|
159
|
+
* new config channel, and it had already MISSED three independent ones —
|
|
160
|
+
* `GIT_CONFIG_PARAMETERS` (a command-line config channel with NO
|
|
161
|
+
* `GIT_CONFIG_COUNT` gate: `GIT_CONFIG_PARAMETERS="'user.name=X'" git config
|
|
162
|
+
* user.name` prints `X`), `GIT_CEILING_DIRECTORIES` (can force the shell-out to
|
|
163
|
+
* fail), and the whole `GIT_TRACE*` family (writes to an attacker-named path as
|
|
164
|
+
* the invoking user). An allowlist omits every unlisted key — present and
|
|
165
|
+
* future — so no future git config channel can ride in.
|
|
166
|
+
*
|
|
167
|
+
* Kept, and only these: `PATH` (execFileSync resolves the `git` binary through
|
|
168
|
+
* it), `HOME` (git's own config-discovery root — the USER's `~/.gitconfig`, not
|
|
169
|
+
* an attacker-set channel), and the locale/tmp/tz vars git honours for messages
|
|
170
|
+
* and temp files. `git show HEAD:<blob>` on a pipe needs nothing else: no pager
|
|
171
|
+
* (not a tty), no default smudge/textconv filter, and aliases cannot shadow the
|
|
172
|
+
* `show` builtin.
|
|
173
|
+
*/
|
|
174
|
+
const GIT_ENV_ALLOWLIST = Object.freeze([
|
|
175
|
+
'PATH',
|
|
176
|
+
'HOME',
|
|
177
|
+
'LANG',
|
|
178
|
+
'LC_ALL',
|
|
179
|
+
'TMPDIR',
|
|
180
|
+
'TZ',
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Resolve the once-per-session banner key.
|
|
185
|
+
*
|
|
186
|
+
* At ESM link-time failure stdin has NOT been read yet, so the payload's
|
|
187
|
+
* `session_id` is unavailable — and `readStdin` lives in `io.mjs`, which may
|
|
188
|
+
* itself be the broken module. So the id is read with `node:fs` alone from
|
|
189
|
+
* `.orchestrator/session.lock`; when that fails we fall back to a time-TTL
|
|
190
|
+
* marker exactly like `run-node.sh` does.
|
|
191
|
+
*
|
|
192
|
+
* ## Why the id alone is not the key (#998.3)
|
|
193
|
+
*
|
|
194
|
+
* There is exactly ONE `session.lock` per working copy, so two parallel sessions
|
|
195
|
+
* in the SAME working copy resolve the same `session_id` — and the second one
|
|
196
|
+
* never sees its own degradation banner, because the first already wrote the
|
|
197
|
+
* marker. The key is therefore composed with a per-session PROCESS identity.
|
|
198
|
+
*
|
|
199
|
+
* **`ppid`, deliberately not `pid`.** Every hook invocation is its own short-lived
|
|
200
|
+
* node process, so a `pid`-keyed marker would be unique per tool call and the
|
|
201
|
+
* banner would fire on every call — the noisy-flood class the throttle exists to
|
|
202
|
+
* prevent, not the shared-marker class it is fixing. `ppid` is the harness
|
|
203
|
+
* process that spawns the hooks and is stable for the session's life:
|
|
204
|
+
* `hooks/run-node.sh` uses `exec node` on every branch, so no intermediate shell
|
|
205
|
+
* survives to become the parent and the ppid IS the harness. This is load-bearing
|
|
206
|
+
* — an added non-exec branch in `run-node.sh` would silently re-break the key.
|
|
207
|
+
*
|
|
208
|
+
* Total key length stays ≤ 64 (48-char id slice + `-pNNNNN`). Markers written in
|
|
209
|
+
* the old (id-only) format live at different paths and are simply never consulted
|
|
210
|
+
* — no migration, the worst case is one extra banner.
|
|
211
|
+
*
|
|
212
|
+
* @param {string} projectDir
|
|
213
|
+
* @returns {{key: string, ttl: boolean}} `ttl: true` means "key is not
|
|
214
|
+
* session-scoped — apply the 6h time TTL instead of pure existence".
|
|
215
|
+
*/
|
|
216
|
+
function resolveBannerKey(projectDir) {
|
|
217
|
+
const proc = `p${typeof process.ppid === 'number' ? process.ppid : 0}`;
|
|
218
|
+
try {
|
|
219
|
+
const raw = fs.readFileSync(path.join(projectDir, '.orchestrator', 'session.lock'), 'utf8');
|
|
220
|
+
const id = JSON.parse(raw)?.session_id;
|
|
221
|
+
if (typeof id === 'string' && id.length > 0) {
|
|
222
|
+
return { key: `${id.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 48)}-${proc}`, ttl: false };
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
/* no lock, unreadable, or malformed — fall through to the time TTL */
|
|
226
|
+
}
|
|
227
|
+
return { key: `ttl-${proc}`, ttl: true };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Per-project marker scope — the digest half of the marker file name. */
|
|
231
|
+
function markerScope(projectDir) {
|
|
232
|
+
return crypto.createHash('sha256').update(projectDir).digest('hex').slice(0, 12);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Coarse boot epoch (unix seconds, bucketed), used to invalidate markers left
|
|
237
|
+
* behind by a previous boot in a persistent `/tmp`. Bucketing absorbs the
|
|
238
|
+
* sub-second jitter between two `os.uptime()` reads; the reader additionally
|
|
239
|
+
* accepts ±1 bucket, so a call straddling a bucket edge is not a false miss.
|
|
240
|
+
*/
|
|
241
|
+
function bootEpochBucket() {
|
|
242
|
+
return Math.round((Date.now() / 1000 - os.uptime()) / BOOT_BUCKET_S);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Absolute path of the marker file that makes the banner once-per-session.
|
|
247
|
+
*
|
|
248
|
+
* Lives in the OS temp dir, NOT in the repo: the error path must not presuppose
|
|
249
|
+
* repo write access, and a marker inside the repo would be another deletable
|
|
250
|
+
* trust anchor. Built with `path.join(os.tmpdir(), …)` rather than string
|
|
251
|
+
* concatenation on `$TMPDIR` — that env var carries a trailing slash on macOS
|
|
252
|
+
* and is unset on a Linux container.
|
|
253
|
+
*
|
|
254
|
+
* The path is intentionally still derivable (it must be, across processes) —
|
|
255
|
+
* which is exactly why the path alone no longer decides anything: see
|
|
256
|
+
* `readMarker` for the payload the file has to carry.
|
|
257
|
+
*
|
|
258
|
+
* @param {string} scope - `markerScope(projectDir)`, so parallel repos (and
|
|
259
|
+
* per-test fixture dirs) never share a marker.
|
|
260
|
+
* @param {string} kind - banner class (`head-fallback`).
|
|
261
|
+
* @param {string} key
|
|
262
|
+
* @returns {string}
|
|
263
|
+
*/
|
|
264
|
+
function bannerMarkerPath(scope, kind, key) {
|
|
265
|
+
return path.join(os.tmpdir(), `session-orchestrator-guard-${kind}-${scope}-${key}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Read + VALIDATE a marker. Returns its write time, or `null` for "no marker of
|
|
270
|
+
* ours here" — which makes the banner fire.
|
|
271
|
+
*
|
|
272
|
+
* Every rejection path is deliberately the fail-LOUD one. A file that exists but
|
|
273
|
+
* does not carry this exact payload (an empty `touch`, a foreign file, a marker
|
|
274
|
+
* from another project, kind, session, or boot) is NOT a suppression signal.
|
|
275
|
+
*
|
|
276
|
+
* `O_NOFOLLOW` matters on both halves of the marker lifecycle: without it the
|
|
277
|
+
* predictable path is an arbitrary-file-write primitive (aim a symlink at any
|
|
278
|
+
* file the session can write, and the marker write truncates it) and an
|
|
279
|
+
* arbitrary-file-READ oracle.
|
|
280
|
+
*
|
|
281
|
+
* @param {string} marker
|
|
282
|
+
* @param {{kind: string, key: string, scope: string}} expected
|
|
283
|
+
* @returns {{at: number}|null}
|
|
284
|
+
*/
|
|
285
|
+
function readMarker(marker, expected) {
|
|
286
|
+
let fd;
|
|
287
|
+
try {
|
|
288
|
+
fd = fs.openSync(marker, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
289
|
+
const st = fs.fstatSync(fd);
|
|
290
|
+
// Regular file, single link, owned by us. A hard link or a foreign-uid file
|
|
291
|
+
// means somebody else controls this path — never honour it.
|
|
292
|
+
if (!st.isFile() || st.nlink !== 1) return null;
|
|
293
|
+
if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return null;
|
|
294
|
+
|
|
295
|
+
const raw = fs.readFileSync(fd, 'utf8');
|
|
296
|
+
const rec = JSON.parse(raw);
|
|
297
|
+
if (rec?.magic !== MARKER_MAGIC || rec?.v !== MARKER_VERSION) return null;
|
|
298
|
+
if (rec.kind !== expected.kind || rec.key !== expected.key || rec.scope !== expected.scope) {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
if (Math.abs(Number(rec.boot) - bootEpochBucket()) > 1) return null;
|
|
302
|
+
|
|
303
|
+
const at = Date.parse(rec.at);
|
|
304
|
+
if (!Number.isFinite(at) || at > Date.now() + 60_000) return null; // no future stamps
|
|
305
|
+
return { at };
|
|
306
|
+
} catch {
|
|
307
|
+
return null; // absent, symlinked (ELOOP), unreadable, or malformed
|
|
308
|
+
} finally {
|
|
309
|
+
if (fd !== undefined) {
|
|
310
|
+
try { fs.closeSync(fd); } catch { /* nothing to do on the error path */ }
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Create the marker exclusively. Never overwrites: `O_EXCL` fails when anything
|
|
317
|
+
* already sits at the path, and a pre-planted file is therefore left alone —
|
|
318
|
+
* the banner then simply repeats on every call, which is the safe direction.
|
|
319
|
+
*
|
|
320
|
+
* @param {string} marker
|
|
321
|
+
* @param {{kind: string, key: string, scope: string}} fields
|
|
322
|
+
*/
|
|
323
|
+
function writeMarker(marker, fields) {
|
|
324
|
+
let fd;
|
|
325
|
+
try {
|
|
326
|
+
fd = fs.openSync(
|
|
327
|
+
marker,
|
|
328
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW ?? 0),
|
|
329
|
+
0o600
|
|
330
|
+
);
|
|
331
|
+
fs.writeSync(
|
|
332
|
+
fd,
|
|
333
|
+
`${JSON.stringify({
|
|
334
|
+
magic: MARKER_MAGIC,
|
|
335
|
+
v: MARKER_VERSION,
|
|
336
|
+
...fields,
|
|
337
|
+
boot: bootEpochBucket(),
|
|
338
|
+
at: new Date().toISOString(),
|
|
339
|
+
})}\n`
|
|
340
|
+
);
|
|
341
|
+
} catch {
|
|
342
|
+
/* unwritable tmp / already present: emit anyway, repeatedly if need be */
|
|
343
|
+
} finally {
|
|
344
|
+
if (fd !== undefined) {
|
|
345
|
+
try { fs.closeSync(fd); } catch { /* nothing to do on the error path */ }
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Write stderr unconditionally. stdout is NEVER an option here: stdout is the
|
|
352
|
+
* decision channel and an allow REQUIRES an empty stdout (see
|
|
353
|
+
* `tests/_helpers/hook-decision.mjs`) — a banner there would corrupt every
|
|
354
|
+
* decision this hook makes.
|
|
355
|
+
*
|
|
356
|
+
* @param {string} message
|
|
357
|
+
*/
|
|
358
|
+
function writeBanner(message) {
|
|
359
|
+
process.stderr.write(message.endsWith('\n') ? message : `${message}\n`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Write a guard-DEGRADATION banner to stderr, at most once per session.
|
|
364
|
+
*
|
|
365
|
+
* Throttling is confined to the degraded class on purpose: there the guard is
|
|
366
|
+
* still armed, so a per-call banner is pure noise an operator learns to ignore.
|
|
367
|
+
* The total-outage banner does NOT come through here — see
|
|
368
|
+
* `emitGuardInactiveBanner`.
|
|
369
|
+
*
|
|
370
|
+
* @param {{projectDir: string, kind: string, message: string}} opts
|
|
371
|
+
* @returns {boolean} whether the banner was emitted this call.
|
|
372
|
+
*/
|
|
373
|
+
function emitGuardBannerOnce({ projectDir, kind, message }) {
|
|
374
|
+
const { key, ttl } = resolveBannerKey(projectDir);
|
|
375
|
+
const scope = markerScope(projectDir);
|
|
376
|
+
const marker = bannerMarkerPath(scope, kind, key);
|
|
377
|
+
const fields = { kind, key, scope };
|
|
378
|
+
|
|
379
|
+
const existing = readMarker(marker, fields);
|
|
380
|
+
if (existing) {
|
|
381
|
+
if (!ttl) return false; // session-keyed: already bannered this session
|
|
382
|
+
if (Date.now() - existing.at < BANNER_TTL_MS) return false;
|
|
383
|
+
// Session-id-less TTL expiry: our own marker, verified above — refresh it.
|
|
384
|
+
try { fs.unlinkSync(marker); } catch { /* keep going; the write may still fail */ }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
writeMarker(marker, fields);
|
|
388
|
+
writeBanner(message);
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Read a repo file's committed content via `git show HEAD:<relPath>`.
|
|
394
|
+
*
|
|
395
|
+
* ## Why the git env is scrubbed here (#998.1 — defense in depth)
|
|
396
|
+
*
|
|
397
|
+
* The bytes this returns are handed straight to {@link importFromSource}, i.e.
|
|
398
|
+
* IMPORTED as code inside a deny-capable hook. `-C <repoRoot>` only sets the
|
|
399
|
+
* child's cwd; it does NOT win against `GIT_DIR`, which overrides repository
|
|
400
|
+
* discovery outright. With `GIT_DIR=<foreign repo>/.git` in the environment the
|
|
401
|
+
* call returns a FOREIGN repository's blob at the same relative path, and that
|
|
402
|
+
* blob is then executed — one env var plus one broken module away from code
|
|
403
|
+
* execution in the guard itself. Measured on this loader: without `GIT_DIR`
|
|
404
|
+
* 57,446 bytes of real source, with a foreign `GIT_DIR` 128 bytes of
|
|
405
|
+
* attacker-controlled content. **No vector is currently known by which a Bash
|
|
406
|
+
* command sets the LATER hook process's environment — this is defense in depth,
|
|
407
|
+
* not a fix for a reachable exploit.** It is applied at this single
|
|
408
|
+
* trust-sensitive shell-out rather than at {@link armGuard} entry, because this
|
|
409
|
+
* is the only place whose output becomes code.
|
|
410
|
+
*
|
|
411
|
+
* ## Allowlist, not denylist (LOW-4)
|
|
412
|
+
*
|
|
413
|
+
* The child env is BUILT from {@link GIT_ENV_ALLOWLIST} rather than
|
|
414
|
+
* `process.env` with a hand-maintained set of deletions. `execFileSync`'s `env`
|
|
415
|
+
* REPLACES the child environment wholesale (it does not merge into
|
|
416
|
+
* `process.env`), so every key not on the allowlist is omitted — including
|
|
417
|
+
* every git config/discovery channel the old denylist did not enumerate. The
|
|
418
|
+
* denylist had already MISSED `GIT_CONFIG_PARAMETERS` (an independent
|
|
419
|
+
* command-line config channel with no `GIT_CONFIG_COUNT` gate),
|
|
420
|
+
* `GIT_CEILING_DIRECTORIES`, and the `GIT_TRACE*` family; an allowlist closes
|
|
421
|
+
* those and any channel a future git release adds, which is the only form that
|
|
422
|
+
* survives such a release. See {@link GIT_ENV_ALLOWLIST} for the kept keys and
|
|
423
|
+
* why each is safe.
|
|
424
|
+
*
|
|
425
|
+
* @param {string} repoRoot
|
|
426
|
+
* @param {string} relPath - POSIX, repo-relative.
|
|
427
|
+
* @returns {string} file content at HEAD.
|
|
428
|
+
* @throws when git is absent, the dir is not a repo, or the path is not at HEAD.
|
|
429
|
+
*/
|
|
430
|
+
function readFromHead(repoRoot, relPath) {
|
|
431
|
+
const env = {};
|
|
432
|
+
for (const key of GIT_ENV_ALLOWLIST) {
|
|
433
|
+
if (process.env[key] !== undefined) env[key] = process.env[key];
|
|
434
|
+
}
|
|
435
|
+
return execFileSync('git', ['-C', repoRoot, 'show', `HEAD:${relPath}`], {
|
|
436
|
+
encoding: 'utf8',
|
|
437
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
438
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
439
|
+
env,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Import an ESM module from an in-memory source string.
|
|
445
|
+
*
|
|
446
|
+
* Only sound for a DEPENDENCY-FREE module (or one importing `node:*` only): a
|
|
447
|
+
* `data:` URL has no base for relative specifier resolution. `command-blocker.mjs`
|
|
448
|
+
* qualifies — its single import is `node:path`.
|
|
449
|
+
*
|
|
450
|
+
* @param {string} source
|
|
451
|
+
* @returns {Promise<object>} the module namespace.
|
|
452
|
+
*/
|
|
453
|
+
function importFromSource(source) {
|
|
454
|
+
const b64 = Buffer.from(source, 'utf8').toString('base64');
|
|
455
|
+
return import(`data:text/javascript;base64,${b64}`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Derive a module's repo-relative POSIX path from its import `specifier`,
|
|
460
|
+
* relative to `repoRoot`. This is what `git show HEAD:<relPath>` consumes and
|
|
461
|
+
* what the banners name — it REPLACES the former hard-wired `COMMAND_BLOCKER_REL`
|
|
462
|
+
* constant, so a second `headFallback` module needs no new constant.
|
|
463
|
+
*
|
|
464
|
+
* Accepts a `file:` URL (the shape `pathToFileURL(...).href` produces) or an
|
|
465
|
+
* absolute path; a bare relative specifier is returned verbatim (it cannot be
|
|
466
|
+
* resolved against `repoRoot` without guessing the importing module's dir, and
|
|
467
|
+
* `headFallback` callers always pass an absolute `file:` URL).
|
|
468
|
+
*
|
|
469
|
+
* @param {string} specifier
|
|
470
|
+
* @param {string} repoRoot
|
|
471
|
+
* @returns {string} repo-relative POSIX path
|
|
472
|
+
*/
|
|
473
|
+
function deriveRelPath(specifier, repoRoot) {
|
|
474
|
+
let absPath;
|
|
475
|
+
if (typeof specifier === 'string' && specifier.startsWith('file:')) {
|
|
476
|
+
absPath = fileURLToPath(specifier);
|
|
477
|
+
} else if (typeof specifier === 'string' && path.isAbsolute(specifier)) {
|
|
478
|
+
absPath = specifier;
|
|
479
|
+
} else {
|
|
480
|
+
return specifier;
|
|
481
|
+
}
|
|
482
|
+
return path.relative(repoRoot, absPath).split(path.sep).join('/');
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Assert a loaded module namespace exports every name in `requires` as a
|
|
487
|
+
* function. A partial namespace is not a degraded guard — it is a guard that
|
|
488
|
+
* throws on the first call and fails open with an `internal error` line, so it
|
|
489
|
+
* must surface here, at the one place that can still fall back or banner.
|
|
490
|
+
*
|
|
491
|
+
* `requires` is passed PER MODULE by the call site — it replaces the former
|
|
492
|
+
* module-wide `COMMAND_BLOCKER_EXPORTS`, which was both hard-wired to one module
|
|
493
|
+
* and already INCOMPLETE (it listed 6 of the 8 exports command-blocker.mjs
|
|
494
|
+
* actually ships). When a spec entry omits `requires`, the shape check is
|
|
495
|
+
* skipped by construction: correct for a module (io.mjs, events.mjs, …) whose
|
|
496
|
+
* missing export surfaces as a plain TypeError at its single call site, with no
|
|
497
|
+
* half-armed fallback to guard against.
|
|
498
|
+
*
|
|
499
|
+
* Why the check must cover ALL required names: it used to assert 2 of 6, so a
|
|
500
|
+
* HEAD copy OLDER than the working tree — the normal case when a newly added
|
|
501
|
+
* export is the very thing that broke (#982/#983/#988 history) — passed as
|
|
502
|
+
* "DEGRADED, enforcement IS still armed" and then allowed every command with an
|
|
503
|
+
* `⚠ internal error — <fn> is not a function` line.
|
|
504
|
+
*
|
|
505
|
+
* @param {object} mod
|
|
506
|
+
* @param {string} origin - human label for the banner ("working-tree copy" | "HEAD copy")
|
|
507
|
+
* @param {string[]} requires - export names that must be functions
|
|
508
|
+
* @param {string} relPath - repo-relative path, for the error message
|
|
509
|
+
* @throws {Error} naming every missing export.
|
|
510
|
+
*/
|
|
511
|
+
function assertShape(mod, origin, requires, relPath) {
|
|
512
|
+
const missing = requires.filter((name) => typeof mod?.[name] !== 'function');
|
|
513
|
+
if (missing.length > 0) {
|
|
514
|
+
throw new Error(
|
|
515
|
+
`${origin} of ${relPath} is missing required export(s): ${missing.join(', ')}`
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Arm a deny-capable hook's repo dependencies, making every load failure VISIBLE
|
|
522
|
+
* and — for the dependency-free modules that opt in — recoverable from HEAD.
|
|
523
|
+
*
|
|
524
|
+
* The generalised successor to the former `loadCommandBlocker` (#993): the hook
|
|
525
|
+
* name, the required-export set, and which modules get a HEAD fallback are ALL
|
|
526
|
+
* parameters now, so `enforce-scope`, `enforce-commands` and
|
|
527
|
+
* `sessions-ledger-guard` share this one loader without each re-hard-wiring
|
|
528
|
+
* `pre-bash-destructive-guard` into a banner literal.
|
|
529
|
+
*
|
|
530
|
+
* ## The frozen contract (A2/A3 build on this — #993)
|
|
531
|
+
*
|
|
532
|
+
* @param {Record<string, {specifier: string, headFallback?: boolean, requires?: string[]}>} specMap
|
|
533
|
+
* One entry per module the hook binds, keyed by a stable LABEL the caller reads
|
|
534
|
+
* back from the returned `modules`. `specifier` is the normal-operation import
|
|
535
|
+
* URL (an absolute `file:` URL for `headFallback` entries — a relative one
|
|
536
|
+
* cannot be resolved from this module). `headFallback: true` opts a
|
|
537
|
+
* DEPENDENCY-FREE module into the `git show HEAD:` recovery (legal only for a
|
|
538
|
+
* {@link HEAD_FALLBACK_ALLOWLIST} basename — a hard error otherwise).
|
|
539
|
+
* `requires` lists the export names that must be functions; omit it to skip the
|
|
540
|
+
* shape check for that module.
|
|
541
|
+
* @param {{hookName: string, repoRoot: string, projectDir: string, consequence?: {degraded?: string[], inactive?: string[]}}} opts
|
|
542
|
+
* `hookName` is MANDATORY — no default, because a default would re-freeze the
|
|
543
|
+
* #993 drift. `repoRoot` is where `git show` runs; `projectDir` keys the
|
|
544
|
+
* once-per-session degradation banner; `consequence.degraded` is spliced,
|
|
545
|
+
* verbatim, into the DEGRADED banner.
|
|
546
|
+
* @returns {Promise<{modules: Record<string, object>, degraded: string[]}>}
|
|
547
|
+
* `modules` maps each label to its namespace; `degraded` lists the labels that
|
|
548
|
+
* loaded from HEAD (empty in the healthy path).
|
|
549
|
+
* @throws the ORIGINAL working-tree error (with `.headFallbackError` attached
|
|
550
|
+
* when a HEAD fallback also failed) so the caller's catch can banner GUARD
|
|
551
|
+
* INACTIVE with the real cause.
|
|
552
|
+
*/
|
|
553
|
+
export async function armGuard(specMap, { hookName, repoRoot, projectDir, consequence } = {}) {
|
|
554
|
+
if (typeof hookName !== 'string' || hookName.length === 0) {
|
|
555
|
+
throw new Error(
|
|
556
|
+
'armGuard: hookName is required and has no default — a default would reintroduce the exact #993 drift this refactor removes.'
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Insertion order, EXCEPT headFallback entries move LAST: the cheap plain
|
|
561
|
+
// imports fail first, so a broken dep-free module never pays for a pointless
|
|
562
|
+
// `git show` on a headFallback module that would have loaded fine.
|
|
563
|
+
const entries = Object.entries(specMap);
|
|
564
|
+
entries.sort(([, a], [, b]) => (a.headFallback ? 1 : 0) - (b.headFallback ? 1 : 0));
|
|
565
|
+
|
|
566
|
+
const modules = {};
|
|
567
|
+
const degraded = [];
|
|
568
|
+
|
|
569
|
+
for (const [label, spec] of entries) {
|
|
570
|
+
const { specifier, headFallback = false, requires } = spec;
|
|
571
|
+
const relPath = deriveRelPath(specifier, repoRoot);
|
|
572
|
+
|
|
573
|
+
if (!headFallback) {
|
|
574
|
+
// No fallback: a missing export or parse error is a plain throw the caller
|
|
575
|
+
// banners as GUARD INACTIVE. No `git show`, no half-arming to guard.
|
|
576
|
+
const module = await import(specifier);
|
|
577
|
+
if (Array.isArray(requires)) assertShape(module, 'working-tree copy', requires, relPath);
|
|
578
|
+
modules[label] = module;
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// headFallback is sound ONLY for a dependency-free (node:*-only) module — a
|
|
583
|
+
// data: URL cannot resolve the relative imports of e.g. hardening.mjs, so a
|
|
584
|
+
// headFallback:true there would produce a silently-unloadable fallback. Fail
|
|
585
|
+
// LOUD on the misconfiguration instead of arming a guard that can never
|
|
586
|
+
// recover.
|
|
587
|
+
if (!HEAD_FALLBACK_ALLOWLIST.has(path.posix.basename(relPath))) {
|
|
588
|
+
throw new Error(
|
|
589
|
+
`armGuard: headFallback:true is only sound for a dependency-free module ` +
|
|
590
|
+
`(a data: URL cannot resolve relative imports); '${relPath}' is not on the allowlist ` +
|
|
591
|
+
`[${[...HEAD_FALLBACK_ALLOWLIST].join(', ')}].`
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
try {
|
|
596
|
+
const module = await import(specifier);
|
|
597
|
+
// The shape check runs on BOTH paths. A working-tree copy that parses but
|
|
598
|
+
// lost an export is the same defect class as a stale HEAD copy: without
|
|
599
|
+
// this it would arm "successfully" and then fail open per command.
|
|
600
|
+
if (Array.isArray(requires)) assertShape(module, 'working-tree copy', requires, relPath);
|
|
601
|
+
modules[label] = module;
|
|
602
|
+
} catch (workingTreeError) {
|
|
603
|
+
let headMod;
|
|
604
|
+
try {
|
|
605
|
+
headMod = await importFromSource(readFromHead(repoRoot, relPath));
|
|
606
|
+
// A HEAD copy that parses but lost part of the API is NOT a usable
|
|
607
|
+
// fallback — treat it as a total failure rather than half-arming.
|
|
608
|
+
if (Array.isArray(requires)) assertShape(headMod, 'HEAD copy', requires, relPath);
|
|
609
|
+
} catch (headError) {
|
|
610
|
+
workingTreeError.headFallbackError = headError;
|
|
611
|
+
throw workingTreeError;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
emitGuardBannerOnce({
|
|
615
|
+
projectDir,
|
|
616
|
+
kind: 'head-fallback',
|
|
617
|
+
message: [
|
|
618
|
+
'',
|
|
619
|
+
`⚠️ ${hookName}: DEGRADED — running against HEAD, not your working tree.`,
|
|
620
|
+
` ${relPath} failed to load from the working tree:`,
|
|
621
|
+
` ${String(workingTreeError?.message || workingTreeError).split('\n')[0]}`,
|
|
622
|
+
...(consequence?.degraded ?? []),
|
|
623
|
+
` Fix: repair ${relPath} (conflict markers? partial edit?) and re-run.`,
|
|
624
|
+
' See: issue #992.',
|
|
625
|
+
'',
|
|
626
|
+
].join('\n'),
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
modules[label] = headMod;
|
|
630
|
+
degraded.push(label);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return { modules, degraded };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Banner for the total-failure case: no usable module, guard NOT armed.
|
|
639
|
+
*
|
|
640
|
+
* **Deliberately NOT throttled** — this is the one message that must survive
|
|
641
|
+
* every attempt to quiet it. Its predecessor went through the once-per-session
|
|
642
|
+
* marker, and because that marker was suppressible by a bare `touch` on a
|
|
643
|
+
* derivable path, the loudest signal in the system had the weakest lock on it:
|
|
644
|
+
* plant the file, break a module, and an unarmed guard said nothing at all
|
|
645
|
+
* while the decision channel said ALLOW. There is nothing to key on here
|
|
646
|
+
* anyway — every call after the first is equally unprotected, so every call
|
|
647
|
+
* has equal right to say so. Repetition is the point.
|
|
648
|
+
*
|
|
649
|
+
* `hookName` is MANDATORY and has no default (see {@link armGuard}) — a default
|
|
650
|
+
* would silently reintroduce the #993 drift, naming one hook in every hook's
|
|
651
|
+
* banner. `consequence.inactive` is spliced verbatim, so each hook states the
|
|
652
|
+
* concrete commands its outage stops blocking. `projectDir` is accepted for
|
|
653
|
+
* signature symmetry with the degraded banner; it deliberately gates nothing.
|
|
654
|
+
*
|
|
655
|
+
* @param {{hookName: string, projectDir?: string, error: unknown, consequence?: {inactive?: string[]}}} opts
|
|
656
|
+
*/
|
|
657
|
+
export function emitGuardInactiveBanner({ hookName, error, consequence } = {}) {
|
|
658
|
+
if (typeof hookName !== 'string' || hookName.length === 0) {
|
|
659
|
+
throw new Error(
|
|
660
|
+
'emitGuardInactiveBanner: hookName is required and has no default (see armGuard — a default would reintroduce the #993 drift).'
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
const primary = String(error?.message || error).split('\n')[0];
|
|
664
|
+
const secondary = error?.headFallbackError
|
|
665
|
+
? String(error.headFallbackError.message || error.headFallbackError).split('\n')[0]
|
|
666
|
+
: null;
|
|
667
|
+
|
|
668
|
+
writeBanner(
|
|
669
|
+
[
|
|
670
|
+
'',
|
|
671
|
+
`🚨 ${hookName}: GUARD INACTIVE — this session is NOT protected.`,
|
|
672
|
+
` Module load failed: ${primary}`,
|
|
673
|
+
...(secondary ? [` HEAD fallback also failed: ${secondary}`] : []),
|
|
674
|
+
...(consequence?.inactive ?? []),
|
|
675
|
+
' Fix: repair the failing module under scripts/lib/, then re-run.',
|
|
676
|
+
' See: issue #992, .claude/rules/parallel-sessions.md (PSA-003).',
|
|
677
|
+
'',
|
|
678
|
+
].join('\n')
|
|
679
|
+
);
|
|
680
|
+
}
|