session-orchestrator 3.19.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/CHANGELOG.md +80 -0
- package/README.md +9 -9
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +1 -1
- package/docs/instruction-delivery.md +350 -0
- package/docs/session-config-reference.md +1 -41
- package/docs/session-config-template.md +0 -23
- package/hooks/_lib/guard-source-loader.mjs +304 -91
- package/hooks/enforce-commands.mjs +216 -17
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +7 -4
- package/hooks/pre-bash-destructive-guard.mjs +146 -59
- package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
- package/package.json +2 -2
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/emit-session.mjs +3 -40
- package/scripts/lib/command-blocker.mjs +322 -62
- package/scripts/lib/hardening.mjs +9 -9
- 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/select.mjs +550 -0
- package/scripts/lib/reconcile/emitter.mjs +107 -22
- package/scripts/lib/reconcile/engine.mjs +9 -15
- 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/scope-gate.mjs +194 -72
- package/scripts/lib/session-close-backfill.mjs +2 -2
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- 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-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +31 -5
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/print-applicable-rules.mjs +170 -7
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/skills/brainstorm/soul.md +47 -1
- package/skills/evolve/SKILL.md +116 -18
- package/skills/gitlab-ops/SKILL.md +5 -0
- package/skills/grill/soul.md +44 -1
- package/skills/plan/soul.md +46 -3
- package/skills/session-end/SKILL.md +1 -24
- package/skills/session-end/phase-3-6-tail.md +30 -1
- package/skills/session-end/plan-verification.md +1 -5
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-start/SKILL.md +2 -0
- package/skills/session-start/soul.md +41 -1
- package/skills/wave-executor/SKILL.md +1 -5
- package/skills/wave-executor/wave-loop.md +36 -71
|
@@ -938,29 +938,6 @@ config-protection:
|
|
|
938
938
|
mode: warn # warn | strict (strict blocks loosening, exit 2)
|
|
939
939
|
allow-config-weakening: false # per-session bypass (mirrors allow-destructive-ops)
|
|
940
940
|
|
|
941
|
-
# Strategic compact-nudge (ecc-analysis / #620)
|
|
942
|
-
compact-nudge:
|
|
943
|
-
enabled: false # opt-in advisory /compact nudge at inter-wave checkpoints
|
|
944
|
-
after: [discovery, impl] # subset of {discovery, impl, failed-wave}
|
|
945
|
-
# NOTE: `discovery` / `impl` are wave ROLES — the §3c gate matches them against
|
|
946
|
-
# the wave's role string. `failed-wave` is NOT a role; it keys off the wave's
|
|
947
|
-
# failure OUTCOME (any wave that did not pass its quality gate), so it can fire
|
|
948
|
-
# after a wave of any role.
|
|
949
|
-
mode: warn # warn | off
|
|
950
|
-
|
|
951
|
-
# /goal continuation anchor at named seams (Lever 5 / #636)
|
|
952
|
-
goal-integration:
|
|
953
|
-
enabled: false # opt-in advisory; default off — zero behaviour change when absent
|
|
954
|
-
seams: [session-end-backlog, inter-wave-fixloop] # subset of {session-end-backlog, inter-wave-fixloop}
|
|
955
|
-
# ADVISORY ONLY. Surfaces ONE suggested /goal command at the named seam(s);
|
|
956
|
-
# never auto-invokes /goal, never blocks. /goal = Adapter (ADR-0010): the
|
|
957
|
-
# evaluator reads the transcript and runs NO tools — it anchors CONTINUATION,
|
|
958
|
-
# never JUDGMENT. Deterministic exit-code gates (npm test/typecheck/lint) stay
|
|
959
|
-
# the completion authority. ONE goal per session — the two seams cannot both
|
|
960
|
-
# hold an active goal; the operator picks one. See `.claude/rules/loop-and-monitor.md`
|
|
961
|
-
# § LM-008, `skills/session-end/SKILL.md § 1.3a`, and
|
|
962
|
-
# `skills/wave-executor/wave-loop.md § /goal Continuation Anchor`.
|
|
963
|
-
|
|
964
941
|
# Webhooks (URLs are required when used — no defaults)
|
|
965
942
|
# webhooks:
|
|
966
943
|
# slack:
|
|
@@ -30,12 +30,25 @@
|
|
|
30
30
|
* began routing around. The banner therefore names the CONSEQUENCE ("guard
|
|
31
31
|
* INACTIVE" / "running against HEAD, not your working tree"), not just a
|
|
32
32
|
* file path.
|
|
33
|
-
* 2. **`git show HEAD:<path>` fallback — for
|
|
34
|
-
* module
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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.
|
|
39
52
|
*
|
|
40
53
|
* **The coupling is mandatory: the fallback must never fire silently.** A
|
|
41
54
|
* successful HEAD fallback banners too ("running against HEAD, not the working
|
|
@@ -98,6 +111,7 @@ import fs from 'node:fs';
|
|
|
98
111
|
import os from 'node:os';
|
|
99
112
|
import path from 'node:path';
|
|
100
113
|
import crypto from 'node:crypto';
|
|
114
|
+
import { fileURLToPath } from 'node:url';
|
|
101
115
|
import { execFileSync } from 'node:child_process';
|
|
102
116
|
|
|
103
117
|
/** TTL for the session-id-less marker fallback, mirroring `run-node.sh` (6h). */
|
|
@@ -110,33 +124,61 @@ const MARKER_VERSION = 1;
|
|
|
110
124
|
/** Bucket width for the boot-epoch field (seconds), and its accepted drift. */
|
|
111
125
|
const BOOT_BUCKET_S = 10;
|
|
112
126
|
|
|
113
|
-
/**
|
|
114
|
-
|
|
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
|
+
]);
|
|
115
151
|
|
|
116
152
|
/**
|
|
117
|
-
* The
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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.
|
|
131
173
|
*/
|
|
132
|
-
const
|
|
133
|
-
'
|
|
134
|
-
'
|
|
135
|
-
'
|
|
136
|
-
'
|
|
137
|
-
'
|
|
138
|
-
'
|
|
139
|
-
];
|
|
174
|
+
const GIT_ENV_ALLOWLIST = Object.freeze([
|
|
175
|
+
'PATH',
|
|
176
|
+
'HOME',
|
|
177
|
+
'LANG',
|
|
178
|
+
'LC_ALL',
|
|
179
|
+
'TMPDIR',
|
|
180
|
+
'TZ',
|
|
181
|
+
]);
|
|
140
182
|
|
|
141
183
|
/**
|
|
142
184
|
* Resolve the once-per-session banner key.
|
|
@@ -147,21 +189,42 @@ const COMMAND_BLOCKER_EXPORTS = [
|
|
|
147
189
|
* `.orchestrator/session.lock`; when that fails we fall back to a time-TTL
|
|
148
190
|
* marker exactly like `run-node.sh` does.
|
|
149
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
|
+
*
|
|
150
212
|
* @param {string} projectDir
|
|
151
213
|
* @returns {{key: string, ttl: boolean}} `ttl: true` means "key is not
|
|
152
214
|
* session-scoped — apply the 6h time TTL instead of pure existence".
|
|
153
215
|
*/
|
|
154
216
|
function resolveBannerKey(projectDir) {
|
|
217
|
+
const proc = `p${typeof process.ppid === 'number' ? process.ppid : 0}`;
|
|
155
218
|
try {
|
|
156
219
|
const raw = fs.readFileSync(path.join(projectDir, '.orchestrator', 'session.lock'), 'utf8');
|
|
157
220
|
const id = JSON.parse(raw)?.session_id;
|
|
158
221
|
if (typeof id === 'string' && id.length > 0) {
|
|
159
|
-
return { key: id.replace(/[^A-Za-z0-9._-]/g, '_').slice(0,
|
|
222
|
+
return { key: `${id.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 48)}-${proc}`, ttl: false };
|
|
160
223
|
}
|
|
161
224
|
} catch {
|
|
162
225
|
/* no lock, unreadable, or malformed — fall through to the time TTL */
|
|
163
226
|
}
|
|
164
|
-
return { key:
|
|
227
|
+
return { key: `ttl-${proc}`, ttl: true };
|
|
165
228
|
}
|
|
166
229
|
|
|
167
230
|
/** Per-project marker scope — the digest half of the marker file name. */
|
|
@@ -329,16 +392,51 @@ function emitGuardBannerOnce({ projectDir, kind, message }) {
|
|
|
329
392
|
/**
|
|
330
393
|
* Read a repo file's committed content via `git show HEAD:<relPath>`.
|
|
331
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
|
+
*
|
|
332
425
|
* @param {string} repoRoot
|
|
333
426
|
* @param {string} relPath - POSIX, repo-relative.
|
|
334
427
|
* @returns {string} file content at HEAD.
|
|
335
428
|
* @throws when git is absent, the dir is not a repo, or the path is not at HEAD.
|
|
336
429
|
*/
|
|
337
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
|
+
}
|
|
338
435
|
return execFileSync('git', ['-C', repoRoot, 'show', `HEAD:${relPath}`], {
|
|
339
436
|
encoding: 'utf8',
|
|
340
437
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
341
438
|
maxBuffer: 8 * 1024 * 1024,
|
|
439
|
+
env,
|
|
342
440
|
});
|
|
343
441
|
}
|
|
344
442
|
|
|
@@ -358,73 +456,182 @@ function importFromSource(source) {
|
|
|
358
456
|
}
|
|
359
457
|
|
|
360
458
|
/**
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
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.
|
|
366
504
|
*
|
|
367
505
|
* @param {object} mod
|
|
368
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
|
|
369
509
|
* @throws {Error} naming every missing export.
|
|
370
510
|
*/
|
|
371
|
-
function
|
|
372
|
-
const missing =
|
|
511
|
+
function assertShape(mod, origin, requires, relPath) {
|
|
512
|
+
const missing = requires.filter((name) => typeof mod?.[name] !== 'function');
|
|
373
513
|
if (missing.length > 0) {
|
|
374
514
|
throw new Error(
|
|
375
|
-
`${origin} of ${
|
|
515
|
+
`${origin} of ${relPath} is missing required export(s): ${missing.join(', ')}`
|
|
376
516
|
);
|
|
377
517
|
}
|
|
378
518
|
}
|
|
379
519
|
|
|
380
520
|
/**
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
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.
|
|
389
552
|
*/
|
|
390
|
-
export async function
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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;
|
|
408
580
|
}
|
|
409
581
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
`
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
+
}
|
|
427
632
|
}
|
|
633
|
+
|
|
634
|
+
return { modules, degraded };
|
|
428
635
|
}
|
|
429
636
|
|
|
430
637
|
/**
|
|
@@ -439,12 +646,20 @@ export async function loadCommandBlocker({ specifier, repoRoot, projectDir }) {
|
|
|
439
646
|
* anyway — every call after the first is equally unprotected, so every call
|
|
440
647
|
* has equal right to say so. Repetition is the point.
|
|
441
648
|
*
|
|
442
|
-
* `
|
|
443
|
-
*
|
|
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.
|
|
444
654
|
*
|
|
445
|
-
* @param {{projectDir?: string, error: unknown}} opts
|
|
655
|
+
* @param {{hookName: string, projectDir?: string, error: unknown, consequence?: {inactive?: string[]}}} opts
|
|
446
656
|
*/
|
|
447
|
-
export function emitGuardInactiveBanner({ error }) {
|
|
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
|
+
}
|
|
448
663
|
const primary = String(error?.message || error).split('\n')[0];
|
|
449
664
|
const secondary = error?.headFallbackError
|
|
450
665
|
? String(error.headFallbackError.message || error.headFallbackError).split('\n')[0]
|
|
@@ -453,12 +668,10 @@ export function emitGuardInactiveBanner({ error }) {
|
|
|
453
668
|
writeBanner(
|
|
454
669
|
[
|
|
455
670
|
'',
|
|
456
|
-
|
|
671
|
+
`🚨 ${hookName}: GUARD INACTIVE — this session is NOT protected.`,
|
|
457
672
|
` Module load failed: ${primary}`,
|
|
458
673
|
...(secondary ? [` HEAD fallback also failed: ${secondary}`] : []),
|
|
459
|
-
|
|
460
|
-
' git stash, redirect-truncate of protected artefacts) are NOT being blocked. This is a',
|
|
461
|
-
' BROKEN GUARD, not a policy decision — do not route around it, repair it.',
|
|
674
|
+
...(consequence?.inactive ?? []),
|
|
462
675
|
' Fix: repair the failing module under scripts/lib/, then re-run.',
|
|
463
676
|
' See: issue #992, .claude/rules/parallel-sessions.md (PSA-003).',
|
|
464
677
|
'',
|