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
|
@@ -123,7 +123,12 @@
|
|
|
123
123
|
* "If file exists and the -a flag is not specified, the file will be
|
|
124
124
|
* overwritten"), while the verb is whatever `time` goes on to run. The target
|
|
125
125
|
* is therefore invisible in both `verb` and `args`, which is why
|
|
126
|
-
* `/usr/bin/time -o <ledger> npm test` was allowed and now denies.
|
|
126
|
+
* `/usr/bin/time -o <ledger> npm test` was allowed and now denies.
|
|
127
|
+
* `resolveSegmentVerb` itself supplies the file-vs-option semantics: each
|
|
128
|
+
* write-destination operand is flagged `writesFile: true` from its per-wrapper
|
|
129
|
+
* `fileArgFlags` table (#996.1), so this guard keys on that flag directly and no
|
|
130
|
+
* longer keeps a local `<wrapper>:<flag>` pair list that had to track the lexer's
|
|
131
|
+
* grammar by hand. The bash
|
|
127
132
|
* KEYWORD `time` is unaffected: it rejects the flag outright
|
|
128
133
|
* (`bash -c 'time -o x echo hi'` → "-o: command not found"), so only
|
|
129
134
|
* `/usr/bin/time`, `command time` and `env time` can carry it at all.
|
|
@@ -181,6 +186,15 @@
|
|
|
181
186
|
* through. Under the #906 exit-0 protocol, "fail-open" is literally exit 0 with
|
|
182
187
|
* no stdout envelope — the harness reads no decision and proceeds.
|
|
183
188
|
*
|
|
189
|
+
* A LOAD failure is a distinct class since #993: the repo dependencies (`io.mjs`,
|
|
190
|
+
* `command-blocker.mjs`) are bound LATE (dynamic `import()` in `bootstrap()`), so
|
|
191
|
+
* a link-time SyntaxError in either becomes a catchable runtime error that
|
|
192
|
+
* banners GUARD INACTIVE on stderr instead of the pre-#993 exit-1 / 0-byte crash
|
|
193
|
+
* that disarmed the guard invisibly under the exit-0 protocol. The
|
|
194
|
+
* `command-blocker` half additionally recovers its COMMITTED source via
|
|
195
|
+
* `git show HEAD:` (banner: DEGRADED, guard still armed against HEAD) — the whole
|
|
196
|
+
* mechanism lives in `_lib/guard-source-loader.mjs` (`armGuard`).
|
|
197
|
+
*
|
|
184
198
|
* ## Override
|
|
185
199
|
*
|
|
186
200
|
* SO_DISABLED_HOOKS=pre-bash-sessions-ledger-guard (session-level)
|
|
@@ -191,24 +205,141 @@
|
|
|
191
205
|
* failure this hook exists to replace.
|
|
192
206
|
*/
|
|
193
207
|
|
|
194
|
-
import {
|
|
195
|
-
// Single direct import from the source module (#991), matching the precedent in
|
|
196
|
-
// hooks/pre-bash-destructive-guard.mjs: the hardening.mjs barrel re-exports
|
|
197
|
-
// tokenizeCommand from this very module (same instance either way) but
|
|
198
|
-
// deliberately does NOT re-export the #982/#983 primitives, so importing the
|
|
199
|
-
// lexer from the barrel and the wrapper resolver from the source would give
|
|
200
|
-
// this hook two dependency edges to one module. The barrel is unchanged.
|
|
201
|
-
import {
|
|
202
|
-
tokenizeCommand,
|
|
203
|
-
resolveSegmentVerb,
|
|
204
|
-
splitChainSegments,
|
|
205
|
-
} from '../scripts/lib/command-blocker.mjs';
|
|
208
|
+
import { spawnSync } from 'node:child_process';
|
|
206
209
|
import path from 'node:path';
|
|
210
|
+
import { pathToFileURL } from 'node:url';
|
|
207
211
|
|
|
208
212
|
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
209
213
|
// #211: exit 0 immediately (silent allow) when this hook is disabled via profile/env
|
|
210
214
|
if (!shouldRunHook('pre-bash-sessions-ledger-guard')) process.exit(0);
|
|
211
215
|
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// #993 — late-bound repo dependencies
|
|
218
|
+
//
|
|
219
|
+
// `io.mjs` (readStdin/emitAllow/emitDeny) and `command-blocker.mjs`
|
|
220
|
+
// (tokenizeCommand/resolveSegmentVerb/splitChainSegments) used to be STATIC
|
|
221
|
+
// imports. A SyntaxError in either failed at ESM LINK time, before the first
|
|
222
|
+
// statement here ran: node exited 1 with 0 bytes on stdout, and the
|
|
223
|
+
// `main().catch(...)` handler at the bottom of this file was structurally
|
|
224
|
+
// unreachable. Under the exit-0 PreToolUse protocol (#906) that crash is, on the
|
|
225
|
+
// only decision-bearing channel, INDISTINGUISHABLE from an explicit
|
|
226
|
+
// `emitAllow()` — the guard failed open and silently.
|
|
227
|
+
//
|
|
228
|
+
// This hook is fail-open BY DESIGN (a nudge, not a boundary — see the module
|
|
229
|
+
// docblock), so a silent disarm is a smaller loss here than in the
|
|
230
|
+
// destructive-guard. It is still a loss: the #958 corruption class stops being
|
|
231
|
+
// caught with no sign it stopped. Binding these late (dynamic `import()` inside
|
|
232
|
+
// `bootstrap()`) turns the link-time crash into a catchable runtime error, which
|
|
233
|
+
// is what makes the GUARD INACTIVE banner in `_lib/guard-source-loader.mjs`
|
|
234
|
+
// reachable at all.
|
|
235
|
+
//
|
|
236
|
+
// `command-blocker.mjs` is held as a NAMESPACE object (`blocker.*`) rather than
|
|
237
|
+
// three destructured bindings on purpose: the required-export list then exists in
|
|
238
|
+
// exactly one place — the `requires` array on the `blocker` spec passed to
|
|
239
|
+
// `armGuard` — which validates BOTH the working-tree copy and the HEAD copy
|
|
240
|
+
// against it, so a partial namespace banners GUARD INACTIVE instead of arming a
|
|
241
|
+
// guard that fails open per command.
|
|
242
|
+
//
|
|
243
|
+
// `profile-gate.mjs` stays static on purpose — it has ZERO imports of its own and
|
|
244
|
+
// gates whether this hook runs at all.
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
/** @type {typeof import('../scripts/lib/io.mjs').readStdin} */ let readStdin;
|
|
247
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow;
|
|
248
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny;
|
|
249
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitWarn} */ let emitWarn;
|
|
250
|
+
/**
|
|
251
|
+
* The whole `command-blocker.mjs` namespace (#991: one direct import path, not
|
|
252
|
+
* via the hardening.mjs barrel, which deliberately does NOT re-export the
|
|
253
|
+
* #982/#983 primitives). Held as ONE object rather than destructured so the
|
|
254
|
+
* required-export set lives only on the `blocker` spec's `requires` array (#993).
|
|
255
|
+
*
|
|
256
|
+
* @type {Record<string, Function>|null}
|
|
257
|
+
*/
|
|
258
|
+
let blocker = null;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Module labels `armGuard` recovered from HEAD because the working-tree copy
|
|
262
|
+
* failed (parse error OR shape check). Non-empty ⇒ this hook is analysing
|
|
263
|
+
* commands with the COMMITTED lexer. Surfaced on the visible stdout channel by
|
|
264
|
+
* {@link flushNotices} (#1001) — the stderr DEGRADED banner alone is discarded
|
|
265
|
+
* under the exit-0 protocol, which made a degraded ALLOW indistinguishable from
|
|
266
|
+
* a healthy one.
|
|
267
|
+
*
|
|
268
|
+
* @type {string[]}
|
|
269
|
+
*/
|
|
270
|
+
let degradedLabels = [];
|
|
271
|
+
|
|
272
|
+
const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
|
|
273
|
+
|
|
274
|
+
/** This hook's name — threaded into the guard banners (#993: no hard-wired literal). */
|
|
275
|
+
const HOOK_NAME = 'pre-bash-sessions-ledger-guard';
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Consequence prose spliced VERBATIM into the DEGRADED and GUARD INACTIVE banners
|
|
279
|
+
* (#993). This hook is fail-open by design, so the `inactive` text says so plainly
|
|
280
|
+
* rather than borrowing the destructive-guard's "do not route around it" framing.
|
|
281
|
+
*/
|
|
282
|
+
const GUARD_CONSEQUENCE = {
|
|
283
|
+
degraded: [
|
|
284
|
+
' Consequence: ledger-write enforcement IS still armed, but it is evaluating the',
|
|
285
|
+
' COMMITTED (HEAD) command lexer — any uncommitted change to that file is NOT in effect.',
|
|
286
|
+
],
|
|
287
|
+
inactive: [
|
|
288
|
+
' Consequence: a direct shell write into .orchestrator/metrics/sessions.jsonl',
|
|
289
|
+
' (>, >>, tee, dd of=, cp/mv destination) is NOT being blocked. This hook is a',
|
|
290
|
+
' fail-open nudge, not a security boundary — but repair it so the #958 corruption',
|
|
291
|
+
' class stays caught.',
|
|
292
|
+
],
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Project dir for banner keying, resolved WITHOUT any repo module — those are the
|
|
297
|
+
* ones that may have failed to load.
|
|
298
|
+
*
|
|
299
|
+
* @returns {string}
|
|
300
|
+
*/
|
|
301
|
+
function bannerProjectDir() {
|
|
302
|
+
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Bind every repo dependency. Throws on any load failure; the caller banners.
|
|
307
|
+
*
|
|
308
|
+
* `io` gets NO HEAD fallback (a missing export surfaces as a plain TypeError at
|
|
309
|
+
* its single call site — no half-armed guard to protect against). `blocker` opts
|
|
310
|
+
* into the `git show HEAD:` recovery (it is dependency-free — its only import is
|
|
311
|
+
* `node:path`) and carries the COMPLETE required-export set, so a partial
|
|
312
|
+
* namespace banners GUARD INACTIVE rather than arming a guard that fails open per
|
|
313
|
+
* command.
|
|
314
|
+
*
|
|
315
|
+
* @returns {Promise<void>}
|
|
316
|
+
*/
|
|
317
|
+
async function bootstrap() {
|
|
318
|
+
const lib = (...seg) => pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', ...seg)).href;
|
|
319
|
+
|
|
320
|
+
const { armGuard } = await import('./_lib/guard-source-loader.mjs');
|
|
321
|
+
const { modules, degraded } = await armGuard(
|
|
322
|
+
{
|
|
323
|
+
io: { specifier: lib('io.mjs') },
|
|
324
|
+
blocker: {
|
|
325
|
+
specifier: lib('command-blocker.mjs'),
|
|
326
|
+
headFallback: true,
|
|
327
|
+
requires: ['tokenizeCommand', 'resolveSegmentVerb', 'splitChainSegments'],
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
hookName: HOOK_NAME,
|
|
332
|
+
repoRoot: PLUGIN_ROOT,
|
|
333
|
+
projectDir: bannerProjectDir(),
|
|
334
|
+
consequence: GUARD_CONSEQUENCE,
|
|
335
|
+
}
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
|
|
339
|
+
blocker = modules.blocker;
|
|
340
|
+
degradedLabels = degraded;
|
|
341
|
+
}
|
|
342
|
+
|
|
212
343
|
// ---------------------------------------------------------------------------
|
|
213
344
|
// Constants
|
|
214
345
|
// ---------------------------------------------------------------------------
|
|
@@ -216,6 +347,10 @@ if (!shouldRunHook('pre-bash-sessions-ledger-guard')) process.exit(0);
|
|
|
216
347
|
/** The ledger's filename. A write target matches on basename equality. */
|
|
217
348
|
const LEDGER_BASENAME = 'sessions.jsonl';
|
|
218
349
|
|
|
350
|
+
/** The repair CLI whose effective apply mode mutates the sessions ledger internally. */
|
|
351
|
+
const REPAIR_SCRIPT_BASENAME = 'repair-invalid-sessions.mjs';
|
|
352
|
+
const REPAIR_APPLY_MARKER = `${REPAIR_SCRIPT_BASENAME} --apply`;
|
|
353
|
+
|
|
219
354
|
/**
|
|
220
355
|
* How much of the offending target may appear in the deny reason.
|
|
221
356
|
*
|
|
@@ -230,19 +365,6 @@ const TARGET_ECHO_MAX = 200;
|
|
|
230
365
|
/** Verbs whose LAST non-flag argument is a write destination. */
|
|
231
366
|
const DEST_LAST_VERBS = new Set(['cp', 'mv']);
|
|
232
367
|
|
|
233
|
-
/**
|
|
234
|
-
* Wrapper flags from {@link resolveSegmentVerb}'s `wrapperArgs` whose operand is
|
|
235
|
-
* a FILE the wrapper itself writes, keyed `<wrapper>:<flag>`.
|
|
236
|
-
*
|
|
237
|
-
* Deliberately an explicit pair list rather than "any argFlag operand": most
|
|
238
|
-
* value-taking wrapper flags name something that is NOT a path — `sudo -u` a
|
|
239
|
-
* user, `nice -n` a niceness, `timeout -k` a duration, `env -u` a variable, and
|
|
240
|
-
* `stdbuf -o` a BUFFERING MODE (`0`, `L`, `4096`), which shares its spelling
|
|
241
|
-
* with `time -o` and would be the obvious false positive of a blanket rule.
|
|
242
|
-
* Only `time -o`/`--output` opens a file for writing.
|
|
243
|
-
*/
|
|
244
|
-
const WRAPPER_FILE_FLAGS = new Set(['time:-o', 'time:--output']);
|
|
245
|
-
|
|
246
368
|
/**
|
|
247
369
|
* How deep to follow a wrapper's command-string payload (`env -S '…'`).
|
|
248
370
|
*
|
|
@@ -282,6 +404,147 @@ function refersToLedger(target) {
|
|
|
282
404
|
return path.posix.basename(normalized) === LEDGER_BASENAME;
|
|
283
405
|
}
|
|
284
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Does this token name the repair CLI by its immediate script operand basename?
|
|
409
|
+
*
|
|
410
|
+
* @param {string} target
|
|
411
|
+
* @returns {boolean}
|
|
412
|
+
*/
|
|
413
|
+
function refersToRepairScript(target) {
|
|
414
|
+
if (typeof target !== 'string' || target.length === 0) return false;
|
|
415
|
+
const normalized = target.replace(/\\/g, '/');
|
|
416
|
+
return path.posix.basename(normalized) === REPAIR_SCRIPT_BASENAME;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Node's own `--help` output is the source of truth for the runtime-option
|
|
421
|
+
* grammar. A hand-maintained option set is necessarily stale as Node adds
|
|
422
|
+
* runtime flags (for example, Node 24's `--experimental-worker-inspection`).
|
|
423
|
+
*
|
|
424
|
+
* The parser only records option names and whether the help specification says
|
|
425
|
+
* the option takes a value. Optional inline values such as `--inspect[=...]`
|
|
426
|
+
* deliberately do not consume the next token: Node treats a separated token as
|
|
427
|
+
* the script in that form. Aliases inherit the value-taking shape of the whole
|
|
428
|
+
* help row (`--loader, --experimental-loader=...`).
|
|
429
|
+
*
|
|
430
|
+
* @returns {{ available: true, grammar: Map<string, { takesNextValue: boolean }> } |
|
|
431
|
+
* { available: false, reason: string }}
|
|
432
|
+
*/
|
|
433
|
+
function loadNodeOptionGrammar() {
|
|
434
|
+
const result = spawnSync(process.execPath, ['--help'], {
|
|
435
|
+
encoding: 'utf8',
|
|
436
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
437
|
+
});
|
|
438
|
+
if (result.error || result.status !== 0 || typeof result.stdout !== 'string') {
|
|
439
|
+
return { available: false, reason: 'spawn-failed' };
|
|
440
|
+
}
|
|
441
|
+
if (!/^Usage: node \[options\]/m.test(result.stdout)) {
|
|
442
|
+
return { available: false, reason: 'malformed-help' };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const grammar = new Map();
|
|
446
|
+
for (const line of result.stdout.split('\n')) {
|
|
447
|
+
const trimmed = line.trim();
|
|
448
|
+
if (!trimmed.startsWith('-')) continue;
|
|
449
|
+
|
|
450
|
+
const specification = trimmed.split(/\s{2,}/, 1)[0];
|
|
451
|
+
const aliases = specification.split(',').map((alias) => alias.trim());
|
|
452
|
+
const names = aliases
|
|
453
|
+
.map((alias) => /^(-{1,2}[A-Za-z0-9][A-Za-z0-9-]*)/.exec(alias)?.[1])
|
|
454
|
+
.filter((name) => typeof name === 'string');
|
|
455
|
+
if (names.length === 0) continue;
|
|
456
|
+
|
|
457
|
+
const takesNextValue = aliases.some((alias) => {
|
|
458
|
+
const equals = alias.indexOf('=');
|
|
459
|
+
const optionalValue = alias.indexOf('[');
|
|
460
|
+
return equals >= 0 && (optionalValue < 0 || equals < optionalValue);
|
|
461
|
+
});
|
|
462
|
+
for (const name of names) grammar.set(name, { takesNextValue });
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// These stable Node help entries make a truncated or otherwise incomplete
|
|
466
|
+
// output unavailable rather than an empty grammar that silently allows.
|
|
467
|
+
if (!grammar.has('--version') || !grammar.has('--eval')) {
|
|
468
|
+
return { available: false, reason: 'malformed-help' };
|
|
469
|
+
}
|
|
470
|
+
return { available: true, grammar };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Parsed lazily so ordinary non-Node Bash commands do not spawn a child process. */
|
|
474
|
+
let nodeOptionGrammar;
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Return Node's runtime-option grammar, loading it only when a Node command
|
|
478
|
+
* actually presents an option token for resolution.
|
|
479
|
+
*
|
|
480
|
+
* @returns {{ available: true, grammar: Map<string, { takesNextValue: boolean }> } |
|
|
481
|
+
* { available: false, reason: string }}
|
|
482
|
+
*/
|
|
483
|
+
function getNodeOptionGrammar() {
|
|
484
|
+
if (!nodeOptionGrammar) nodeOptionGrammar = loadNodeOptionGrammar();
|
|
485
|
+
return nodeOptionGrammar;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Node modes in which a later token is not an executing script operand. */
|
|
489
|
+
const NODE_EVAL_OPTIONS = new Set(['-e', '--eval', '-p', '--print']);
|
|
490
|
+
const NODE_CHECK_OPTIONS = new Set(['-c', '--check']);
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Return the option name without an inline `=value` suffix.
|
|
494
|
+
*
|
|
495
|
+
* @param {string} text
|
|
496
|
+
* @returns {string}
|
|
497
|
+
*/
|
|
498
|
+
function nodeOptionName(text) {
|
|
499
|
+
const equals = text.indexOf('=');
|
|
500
|
+
return equals > 0 ? text.slice(0, equals) : text;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Resolve Node's actual script operand from tokens immediately following `node`.
|
|
505
|
+
*
|
|
506
|
+
* Values consumed by the runtime's own help-derived options are never candidates
|
|
507
|
+
* for the script. Eval/print/check modes have no executing script operand for
|
|
508
|
+
* this guard's contract, so their following tokens cannot activate the repair
|
|
509
|
+
* mutation rule. Unknown options stop resolution rather than causing a later
|
|
510
|
+
* argument to be searched.
|
|
511
|
+
*
|
|
512
|
+
* @param {Array<{ text: string, quoted: boolean }>} args
|
|
513
|
+
* @returns {{ scriptIndex: number }|{ unavailable: true, reason: string }|null}
|
|
514
|
+
*/
|
|
515
|
+
function resolveNodeScriptOperand(args) {
|
|
516
|
+
for (let i = 0; i < args.length;) {
|
|
517
|
+
const text = args[i].text;
|
|
518
|
+
const name = nodeOptionName(text);
|
|
519
|
+
|
|
520
|
+
if (text === '--') return i + 1 < args.length ? { scriptIndex: i + 1 } : null;
|
|
521
|
+
|
|
522
|
+
if (NODE_EVAL_OPTIONS.has(name)) return null;
|
|
523
|
+
if ((text.startsWith('-e') || text.startsWith('-p')) && !text.startsWith('--') && text.length > 2) {
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
if (NODE_CHECK_OPTIONS.has(name)) return null;
|
|
527
|
+
|
|
528
|
+
if (!text.startsWith('-')) return { scriptIndex: i };
|
|
529
|
+
|
|
530
|
+
const grammarState = getNodeOptionGrammar();
|
|
531
|
+
if (!grammarState.available) return { unavailable: true, reason: grammarState.reason };
|
|
532
|
+
|
|
533
|
+
const option = grammarState.grammar.get(name);
|
|
534
|
+
if (option?.takesNextValue) {
|
|
535
|
+
i += text.includes('=') ? 1 : 2;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (option) {
|
|
539
|
+
i++;
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
|
|
285
548
|
/**
|
|
286
549
|
* Skip a quoted run that OPENS at `open`.
|
|
287
550
|
*
|
|
@@ -491,56 +754,134 @@ function scanCommand(command) {
|
|
|
491
754
|
* OPTIONS are consumed with it and `sudo -u root tee -a <ledger>` reaches the
|
|
492
755
|
* real verb instead of stopping at `-u`.
|
|
493
756
|
*
|
|
757
|
+
* DUAL PARSE (#1000). `resolveSegmentVerb` returns an optional `alt` reading —
|
|
758
|
+
* the value-taking interpretation of an unknown dash-flag — whenever the two
|
|
759
|
+
* readings disagree about the verb. Both are judged here and the FIRST hit wins,
|
|
760
|
+
* which is the safe direction: guessing wrong in the boolean direction hid the
|
|
761
|
+
* write verb behind an unrecognised flag, and two commands were MEASURED to slip
|
|
762
|
+
* through that way (`nice --unknown 5 tee -a <ledger>` read the verb as `5`;
|
|
763
|
+
* `env -Q x tee -a <ledger>` read it as `x`). Judging both cannot lose a deny
|
|
764
|
+
* parse A already found — parse A is still evaluated first and unchanged.
|
|
765
|
+
*
|
|
494
766
|
* @param {string} command - sanitized command
|
|
495
767
|
* @param {number} [depth] - payload recursion level (see MAX_PAYLOAD_DEPTH)
|
|
768
|
+
* @param {string[]} [marks] - OUT-param accumulator for fail-visible markers
|
|
769
|
+
* (#998): analysis this pass could not complete, e.g. a payload dropped at the
|
|
770
|
+
* MAX_PAYLOAD_DEPTH cut. Threaded through the recursion so a mark raised in a
|
|
771
|
+
* nested payload reaches `main()`. Never affects the return value — the
|
|
772
|
+
* `string|null` contract is unchanged; marks add VISIBILITY, never a deny.
|
|
496
773
|
* @returns {string|null} the offending target, or null
|
|
497
774
|
*/
|
|
498
|
-
function findWriteVerbTarget(command, depth = 0) {
|
|
499
|
-
for (const segment of splitChainSegments(tokenizeCommand(command))) {
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
775
|
+
function findWriteVerbTarget(command, depth = 0, marks = []) {
|
|
776
|
+
for (const segment of blocker.splitChainSegments(blocker.tokenizeCommand(command))) {
|
|
777
|
+
const resolved = blocker.resolveSegmentVerb(segment);
|
|
778
|
+
const readings = [resolved, resolved.alt].filter(Boolean);
|
|
779
|
+
// Payloads are deduped ACROSS readings: both readings usually report the same
|
|
780
|
+
// `env -S '…'` operand, and recursing twice on one string only doubles work.
|
|
781
|
+
const payloadSet = new Set();
|
|
782
|
+
|
|
783
|
+
for (const { payloads, wrapperArgs } of readings) {
|
|
784
|
+
for (const p of payloads) payloadSet.add(p);
|
|
785
|
+
|
|
786
|
+
// A wrapper can write a file WITHOUT being the verb: `/usr/bin/time -o F`
|
|
787
|
+
// truncates F while the verb is whatever time runs. Checked before the verb
|
|
788
|
+
// dispatch because `time -o <ledger>` alone resolves to verb null. The
|
|
789
|
+
// file-vs-option distinction is the LEXER's now (#996.1): resolveSegmentVerb
|
|
790
|
+
// marks a write-destination operand `writesFile: true` from its per-wrapper
|
|
791
|
+
// `fileArgFlags` table (command-blocker.mjs — the writesFile contract, the
|
|
792
|
+
// `resolveSegmentVerb` return docblock), so a local `<wrapper>:<flag>` pair
|
|
793
|
+
// list here is gone. The rationale it encoded — `stdbuf -o` is a BUFFERING
|
|
794
|
+
// MODE, not a file, and `time -o` is the only wrapper flag that opens one —
|
|
795
|
+
// lives beside that table (command-blocker.mjs, the WRAPPER_UNWRAP docblock).
|
|
796
|
+
for (const wa of wrapperArgs) {
|
|
797
|
+
if (wa.writesFile !== true) continue;
|
|
798
|
+
if (typeof wa.value === 'string' && refersToLedger(wa.value)) return wa.value;
|
|
799
|
+
}
|
|
508
800
|
}
|
|
509
801
|
|
|
510
802
|
// `env -S 'tee -a <ledger>'` hides a whole command line in one operand.
|
|
511
803
|
// Recurse on the payload with the SAME matcher rather than a second,
|
|
512
|
-
// weaker one — bounded by MAX_PAYLOAD_DEPTH.
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
804
|
+
// weaker one — bounded by MAX_PAYLOAD_DEPTH. Kept BEFORE the verb dispatch
|
|
805
|
+
// (pre-#1000 order), so which target a mixed command reports is unchanged.
|
|
806
|
+
if (payloadSet.size > 0) {
|
|
807
|
+
if (depth < MAX_PAYLOAD_DEPTH) {
|
|
808
|
+
for (const payload of payloadSet) {
|
|
809
|
+
const hit = findLedgerWrite(payload, depth + 1, marks);
|
|
810
|
+
if (hit) return hit;
|
|
811
|
+
}
|
|
812
|
+
} else if ([...payloadSet].some((p) => typeof p === 'string' && p.includes(LEDGER_BASENAME))) {
|
|
813
|
+
// The cut USED to be silent (#998 item 2): a payload nested past the cap
|
|
814
|
+
// was dropped with no trace, so an operator could not tell "analysed and
|
|
815
|
+
// clean" from "never looked at". Marked — but ONLY when the dropped
|
|
816
|
+
// payload actually mentions the ledger. An unfiltered mark would turn
|
|
817
|
+
// every ordinary deep-but-benign command into a warn envelope, which is
|
|
818
|
+
// noise this accident-guard must not generate. Still no deny: raising the
|
|
819
|
+
// cap into a deny is a policy change, not a visibility fix.
|
|
820
|
+
marks.push('depth-exceeded');
|
|
517
821
|
}
|
|
518
822
|
}
|
|
519
823
|
|
|
520
|
-
|
|
521
|
-
|
|
824
|
+
for (const { verb, index } of readings) {
|
|
825
|
+
if (!verb) continue;
|
|
826
|
+
// `index` is the verb position OF THIS READING — never mix it with the
|
|
827
|
+
// other's (the lib docblock's positional-walk caveat).
|
|
828
|
+
const args = segment.slice(index + 1);
|
|
522
829
|
|
|
523
|
-
|
|
524
|
-
for
|
|
525
|
-
|
|
526
|
-
|
|
830
|
+
// The repair CLI mutates the ledger from inside Node, so there is no
|
|
831
|
+
// redirect target for the structural matcher to see. Match only its
|
|
832
|
+
// effective apply contract at the resolved token seam: `node`, the
|
|
833
|
+
// actual script operand after Node runtime options, a later exact
|
|
834
|
+
// `--apply`, and no later exact `--dry-run`. The CLI remains the owner of
|
|
835
|
+
// target semantics.
|
|
836
|
+
if (verb === 'node') {
|
|
837
|
+
const script = resolveNodeScriptOperand(args);
|
|
838
|
+
const hasRepairMention = args.some((arg) => refersToRepairScript(arg.text));
|
|
839
|
+
const hasApply = args.some((arg) => arg.text === '--apply');
|
|
840
|
+
const hasDryRun = args.some((arg) => arg.text === '--dry-run');
|
|
841
|
+
|
|
842
|
+
if (script?.unavailable) {
|
|
843
|
+
// The runtime-derived grammar is an enforcement dependency. If it is
|
|
844
|
+
// unavailable, an ambiguous repair invocation must be visible and
|
|
845
|
+
// denied rather than falling through to the hook's normal allow path.
|
|
846
|
+
if (hasRepairMention && hasApply && !hasDryRun) {
|
|
847
|
+
return `${REPAIR_APPLY_MARKER} (Node option grammar unavailable: ${script.reason}; denied fail-closed)`;
|
|
848
|
+
}
|
|
849
|
+
} else {
|
|
850
|
+
const scriptToken = script && args[script.scriptIndex];
|
|
851
|
+
const laterArgs = script ? args.slice(script.scriptIndex + 1) : [];
|
|
852
|
+
if (
|
|
853
|
+
scriptToken &&
|
|
854
|
+
refersToRepairScript(scriptToken.text) &&
|
|
855
|
+
laterArgs.some((arg) => arg.text === '--apply') &&
|
|
856
|
+
!laterArgs.some((arg) => arg.text === '--dry-run')
|
|
857
|
+
) {
|
|
858
|
+
return REPAIR_APPLY_MARKER;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
527
861
|
}
|
|
528
|
-
continue;
|
|
529
|
-
}
|
|
530
862
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
863
|
+
if (verb === 'tee') {
|
|
864
|
+
for (const arg of args) {
|
|
865
|
+
if (!arg.quoted && arg.text.startsWith('-')) continue;
|
|
866
|
+
if (refersToLedger(arg.text)) return arg.text;
|
|
867
|
+
}
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
if (verb === 'dd') {
|
|
872
|
+
for (const arg of args) {
|
|
873
|
+
const m = /^of=(.*)$/.exec(arg.text);
|
|
874
|
+
if (m && refersToLedger(m[1])) return m[1];
|
|
875
|
+
}
|
|
876
|
+
continue;
|
|
535
877
|
}
|
|
536
|
-
continue;
|
|
537
|
-
}
|
|
538
878
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
879
|
+
if (DEST_LAST_VERBS.has(verb)) {
|
|
880
|
+
const operands = args.filter((a) => a.quoted || !a.text.startsWith('-'));
|
|
881
|
+
// `cp a b` writes b; `cp ledger backup` READS the ledger and must pass.
|
|
882
|
+
const dest = operands.length >= 2 ? operands[operands.length - 1] : null;
|
|
883
|
+
if (dest && refersToLedger(dest.text)) return dest.text;
|
|
884
|
+
}
|
|
544
885
|
}
|
|
545
886
|
}
|
|
546
887
|
return null;
|
|
@@ -556,12 +897,16 @@ function findWriteVerbTarget(command, depth = 0) {
|
|
|
556
897
|
*
|
|
557
898
|
* @param {string} command
|
|
558
899
|
* @param {number} [depth] - payload recursion level (see MAX_PAYLOAD_DEPTH)
|
|
900
|
+
* @param {string[]} [marks] - OUT-param accumulator for fail-visible markers
|
|
901
|
+
* (#998); see {@link findWriteVerbTarget}. Purely additive — the `string|null`
|
|
902
|
+
* return contract is unchanged and a mark never becomes a deny.
|
|
559
903
|
* @returns {string|null}
|
|
560
904
|
*/
|
|
561
|
-
function findLedgerWrite(command, depth = 0) {
|
|
905
|
+
function findLedgerWrite(command, depth = 0, marks = []) {
|
|
562
906
|
if (typeof command !== 'string' || command.length === 0) return null;
|
|
563
|
-
// Cheap pre-filter:
|
|
564
|
-
|
|
907
|
+
// Cheap pre-filter: commands naming neither the ledger nor the internal repair
|
|
908
|
+
// CLI cannot reach either deny path.
|
|
909
|
+
if (!command.includes(LEDGER_BASENAME) && !command.includes(REPAIR_SCRIPT_BASENAME)) return null;
|
|
565
910
|
|
|
566
911
|
const scan = scanCommand(command);
|
|
567
912
|
for (const target of scan.targets) {
|
|
@@ -574,7 +919,28 @@ function findLedgerWrite(command, depth = 0) {
|
|
|
574
919
|
if (!scan.balanced) {
|
|
575
920
|
return `${LEDGER_BASENAME} (unbalanced quote — command not parseable, denied fail-closed)`;
|
|
576
921
|
}
|
|
577
|
-
return findWriteVerbTarget(scan.sanitized, depth);
|
|
922
|
+
return findWriteVerbTarget(scan.sanitized, depth, marks);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Flush the aggregated allow-with-notice channel (#1001).
|
|
927
|
+
*
|
|
928
|
+
* Notices accumulate during matching and are emitted ONCE, here, on the VISIBLE
|
|
929
|
+
* stdout channel via `emitWarn` (allow-with-notice) — else a plain `emitAllow`.
|
|
930
|
+
* Both exit 0 and never return, so this is always the LAST statement on an allow
|
|
931
|
+
* path.
|
|
932
|
+
*
|
|
933
|
+
* Why aggregate instead of `emitWarn`-ing inline: `emitWarn` is `@returns never`
|
|
934
|
+
* (scripts/lib/io.mjs), so an inline call before `findLedgerWrite` would exit
|
|
935
|
+
* BEFORE the matcher ran — turning every would-be DENY into an ALLOW-with-notice.
|
|
936
|
+
* The deny path never reaches here and drops the notices by design: DENY wins,
|
|
937
|
+
* and the stderr copies remain for CI/debug.
|
|
938
|
+
*
|
|
939
|
+
* @param {string[]} notices
|
|
940
|
+
* @returns {never}
|
|
941
|
+
*/
|
|
942
|
+
function flushNotices(notices) {
|
|
943
|
+
return notices.length > 0 ? emitWarn(notices.join('\n')) : emitAllow();
|
|
578
944
|
}
|
|
579
945
|
|
|
580
946
|
// ---------------------------------------------------------------------------
|
|
@@ -592,9 +958,39 @@ async function main() {
|
|
|
592
958
|
const command = input?.tool_input?.command;
|
|
593
959
|
if (typeof command !== 'string' || command.length === 0) return emitAllow();
|
|
594
960
|
|
|
961
|
+
// #1001 — aggregated allow-with-notice channel, opened only AFTER G1/G2 (a
|
|
962
|
+
// non-Bash call or an empty command is not this hook's business and stays a
|
|
963
|
+
// bare allow). Flushed ONCE on the allow path; see flushNotices for why an
|
|
964
|
+
// inline emitWarn here would disarm the G4 deny below.
|
|
965
|
+
const notices = [];
|
|
966
|
+
// A guard armed from HEAD is a visible-channel concern: the DEGRADED banner
|
|
967
|
+
// rides stderr only, which exit 0 discards. armGuard already fired it once per
|
|
968
|
+
// session; surface it on stdout so a degraded ALLOW is not silently
|
|
969
|
+
// indistinguishable from a healthy one.
|
|
970
|
+
if (degradedLabels.length > 0) {
|
|
971
|
+
notices.push(
|
|
972
|
+
`${HOOK_NAME}: DEGRADED — guard module(s) loaded from HEAD, not your working tree ` +
|
|
973
|
+
`(${degradedLabels.join(', ')}); uncommitted changes to them are NOT in effect. See #992.`
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
|
|
595
977
|
// G3 — matcher. No direct ledger write → allow.
|
|
596
|
-
const
|
|
597
|
-
|
|
978
|
+
const marks = [];
|
|
979
|
+
const target = findLedgerWrite(command, 0, marks);
|
|
980
|
+
if (!target) {
|
|
981
|
+
// Fail-VISIBLE (#998 item 2): the matcher completed without a hit, but part
|
|
982
|
+
// of the command was never analysed. Say so rather than reporting a clean
|
|
983
|
+
// allow. Still an ALLOW — this hook is an accident-guard, fail-open by
|
|
984
|
+
// design, and a depth cut is not evidence of a write.
|
|
985
|
+
if (marks.length > 0) {
|
|
986
|
+
const msg =
|
|
987
|
+
`${HOOK_NAME}: unresolved payload (${[...new Set(marks)].join(', ')}) — ` +
|
|
988
|
+
`not analysed (fail-visible)`;
|
|
989
|
+
process.stderr.write(`⚠ ${msg}\n`);
|
|
990
|
+
notices.push(msg);
|
|
991
|
+
}
|
|
992
|
+
return flushNotices(notices);
|
|
993
|
+
}
|
|
598
994
|
|
|
599
995
|
// G4 — deny. emitDeny writes the envelope with fs.writeSync (#906/#914): a
|
|
600
996
|
// console.log here would be dropped above the 64 KiB pipe buffer, and a
|
|
@@ -617,6 +1013,37 @@ async function main() {
|
|
|
617
1013
|
);
|
|
618
1014
|
}
|
|
619
1015
|
|
|
1016
|
+
// ---------------------------------------------------------------------------
|
|
1017
|
+
// Entry point (#993)
|
|
1018
|
+
//
|
|
1019
|
+
// TWO distinct failure classes, two distinct handlers — do not merge them:
|
|
1020
|
+
// 1. LOAD failure (`bootstrap()` throws): the guard never armed, nothing was
|
|
1021
|
+
// evaluated. This used to be a bare exit-1 crash with 0 bytes of stdout —
|
|
1022
|
+
// indistinguishable from an allow, and therefore invisible. Now it exits 0
|
|
1023
|
+
// (still fail-open, so a broken module cannot brick the session) but SAYS SO
|
|
1024
|
+
// via the GUARD INACTIVE banner, once loud on stderr.
|
|
1025
|
+
// 2. RUNTIME failure inside `main()`: pre-existing fail-open behaviour,
|
|
1026
|
+
// unchanged (this hook is fail-open by design — see the module docblock).
|
|
1027
|
+
// ---------------------------------------------------------------------------
|
|
1028
|
+
try {
|
|
1029
|
+
await bootstrap();
|
|
1030
|
+
} catch (loadError) {
|
|
1031
|
+
try {
|
|
1032
|
+
const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
|
|
1033
|
+
// hookName is threaded explicitly (#993 — no hard-wired literal in the loader).
|
|
1034
|
+
emitGuardInactiveBanner({ hookName: HOOK_NAME, error: loadError, consequence: GUARD_CONSEQUENCE });
|
|
1035
|
+
} catch {
|
|
1036
|
+
// Last resort: even the banner helper failed to load. Emit unconditionally —
|
|
1037
|
+
// repeated noise beats a silent disarm.
|
|
1038
|
+
process.stderr.write(
|
|
1039
|
+
'🚨 pre-bash-sessions-ledger-guard: GUARD INACTIVE — module load failed ' +
|
|
1040
|
+
`(${String(loadError?.message || loadError).split('\n')[0]}). ` +
|
|
1041
|
+
'Direct shell writes to the sessions ledger are NOT being blocked. See issue #992.\n'
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
process.exit(0); // fail-open, but no longer fail-silent
|
|
1045
|
+
}
|
|
1046
|
+
|
|
620
1047
|
// Top-level error handler — fail-OPEN (see the module docblock). Never let a
|
|
621
1048
|
// non-zero exit leak: on this protocol exit 0 + empty stdout is "no decision".
|
|
622
1049
|
main().catch((e) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "session-orchestrator",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.20.0",
|
|
4
4
|
"description": "Loop engineering for AI coding agents — turn ad-hoc sessions into a repeatable research → plan → wave-execute → close loop with verification gates. Runs on Claude Code, Codex CLI, Cursor, and Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://session-orchestrator.com",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"dependencies": {
|
|
63
63
|
"@babel/parser": "^7.26.0",
|
|
64
64
|
"ajv": "^8.17.1",
|
|
65
|
-
"js-yaml": "^4.
|
|
65
|
+
"js-yaml": "^4.3.1",
|
|
66
66
|
"mdast-util-to-string": "^4.0.0",
|
|
67
67
|
"remark": "^15.0.0",
|
|
68
68
|
"remark-parse": "^11.0.0",
|