session-orchestrator 3.19.0 → 3.21.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 +10 -8
- package/CHANGELOG.md +494 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +1 -1
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/release.md +60 -0
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +1 -1
- package/docs/instruction-delivery.md +350 -0
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -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 +236 -12
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +11 -1
- package/hooks/on-session-end.mjs +52 -5
- package/hooks/on-session-start.mjs +7 -4
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-bash-destructive-guard.mjs +146 -59
- package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +2 -2
- package/pi/prompts/release.md +12 -0
- package/scripts/autopilot.mjs +3 -1
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/emit-session.mjs +45 -40
- package/scripts/export-hw-learnings.mjs +61 -2
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +207 -23
- package/scripts/lib/command-blocker.mjs +322 -62
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +193 -7
- 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 +704 -0
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- 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 +134 -1
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +852 -72
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-close-backfill.mjs +2 -2
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-registry.mjs +9 -1
- 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/state-md/mission-status.mjs +21 -12
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +188 -20
- package/scripts/lib/validate/check-rules.mjs +31 -5
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +757 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +500 -19
- package/scripts/print-applicable-rules.mjs +170 -7
- package/scripts/print-learnings-index.mjs +501 -0
- package/scripts/release.mjs +616 -61
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +82 -0
- package/scripts/validate-wave-scope.mjs +281 -12
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/state-ownership.md +17 -0
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/evolve/SKILL.md +116 -18
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +54 -39
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/soul.md +44 -1
- package/skills/memory-cleanup/SKILL.md +18 -5
- package/skills/npm-publish/SKILL.md +22 -50
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/mode-new.md +23 -5
- package/skills/plan/soul.md +46 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +45 -26
- package/skills/session-end/metrics-collection.md +1 -1
- 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 +6 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +15 -1
- package/skills/session-start/soul.md +41 -1
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/SKILL.md +1 -5
- package/skills/wave-executor/wave-loop.md +77 -82
- package/scripts/lib/mission-status-schema.mjs +0 -114
package/hooks/on-session-end.mjs
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
isLockOwnedByProof,
|
|
40
40
|
OWNER_PROOF_RELPATH,
|
|
41
41
|
} from '../scripts/lib/session-lock.mjs';
|
|
42
|
+
import { deregisterSelf, logSweepEvent } from '../scripts/lib/session-registry.mjs';
|
|
42
43
|
import { attemptLockReconciliation } from './_lib/lock-reconcile.mjs';
|
|
43
44
|
|
|
44
45
|
// ---------------------------------------------------------------------------
|
|
@@ -275,16 +276,17 @@ async function main() {
|
|
|
275
276
|
if (releaseEligible) {
|
|
276
277
|
// Defense-in-depth (#987): when a persisted proof exists, hand it to
|
|
277
278
|
// release() so the delete is double-gated (session_id match AND
|
|
278
|
-
// proof match) at the fs layer too.
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
279
|
+
// proof match) at the fs layer too. `proof` is `null` whenever
|
|
280
|
+
// loadOwnerProof() could not prove ownership (pre-#987 sessions,
|
|
281
|
+
// failed proof write) — release() gates on `proof != null` (#989) and
|
|
282
|
+
// degrades to the session_id-only path for that case, so passing it
|
|
283
|
+
// through unguarded is correct.
|
|
282
284
|
// A 'proof-mismatch' result flows into the existing release_failed
|
|
283
285
|
// breadcrumb below (releaseResult.reason surfaces verbatim).
|
|
284
286
|
const releaseResult = release({
|
|
285
287
|
sessionId: lock.session_id,
|
|
286
288
|
repoRoot: projectRoot,
|
|
287
|
-
|
|
289
|
+
proof,
|
|
288
290
|
});
|
|
289
291
|
// release() has a no-throw contract (always returns a structured
|
|
290
292
|
// result). A matched ownership that still fails to delete — an
|
|
@@ -369,6 +371,51 @@ async function main() {
|
|
|
369
371
|
// 'absent' — no lock file at all; nothing to release or reconcile
|
|
370
372
|
// (mirrors the pre-existing `if (lock)` guard's false branch).
|
|
371
373
|
} catch { /* best-effort — never block teardown */ }
|
|
374
|
+
|
|
375
|
+
// (c) #1047 — host-registry deregistration, the symmetric partner of
|
|
376
|
+
// on-session-start.mjs's registerSelf(). This used to live in
|
|
377
|
+
// hooks/on-stop.mjs, which fires at TURN end, so every assistant turn
|
|
378
|
+
// deleted the entry of a still-live session; on-stop.mjs now refreshes
|
|
379
|
+
// the entry (heartbeat) and teardown happens here, at the real end.
|
|
380
|
+
//
|
|
381
|
+
// Keyed by `sessionId` ONLY, never by `semanticSessionId`: the registry
|
|
382
|
+
// file is named after the id registerSelf() was called with, and in the
|
|
383
|
+
// `generated-uuid-fallback-collision` path (on-session-start.mjs) the
|
|
384
|
+
// semantic candidate names ANOTHER session's entry — deregistering by it
|
|
385
|
+
// would delete a foreign live session. Ownership is therefore structural
|
|
386
|
+
// here, not a check.
|
|
387
|
+
//
|
|
388
|
+
// CONSEQUENCE, accepted deliberately — and the affected platform is
|
|
389
|
+
// CODEX ALONE. Measured 2026-08-17 across the three bridge manifests:
|
|
390
|
+
// hooks.json SessionStart + SessionEnd -> registers, deregisters
|
|
391
|
+
// hooks-pi.json session_start + session_shutdown -> registers, deregisters
|
|
392
|
+
// (session_shutdown maps to THIS file)
|
|
393
|
+
// hooks-codex.json SessionStart + Stop, no SessionEnd
|
|
394
|
+
// -> registers, never deregisters <- the gap
|
|
395
|
+
// hooks-cursor.json afterFileEdit + beforeShellExecution only
|
|
396
|
+
// -> never registers, so nothing to leak
|
|
397
|
+
// On Codex an entry therefore persists until sweepZombies() removes it at
|
|
398
|
+
// the next SessionStart — up to the sweep threshold (`thresholdMin`,
|
|
399
|
+
// default 60 min) after the session ended. That is the same path crash
|
|
400
|
+
// and Ctrl-C already rely on for EVERY platform; no platform-detecting
|
|
401
|
+
// second teardown branch exists by design.
|
|
402
|
+
//
|
|
403
|
+
// Note where this is written: THIS file does not run on Codex, so the
|
|
404
|
+
// consequence is also pointed at from hooks/on-stop.mjs, which does.
|
|
405
|
+
//
|
|
406
|
+
// The `sessionId` guard is not decoration: deregisterSelf() throws
|
|
407
|
+
// TypeError on a null/empty id, and "no id resolvable" is a normal
|
|
408
|
+
// degraded state (no stdin id, no current-session.json), not a failure
|
|
409
|
+
// worth a sweep.log breadcrumb.
|
|
410
|
+
if (sessionId) {
|
|
411
|
+
try {
|
|
412
|
+
await deregisterSelf(sessionId);
|
|
413
|
+
} catch (err) {
|
|
414
|
+
// Deregistration failed — observability breadcrumb to sweep.log, never a
|
|
415
|
+
// throw and never stderr: the hook must not block teardown.
|
|
416
|
+
logSweepEvent({ event: 'deregister-failed', session_id: sessionId, error: err?.message ?? String(err) });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
372
419
|
}
|
|
373
420
|
|
|
374
421
|
// Exit 0 always — informational hook must never block session teardown.
|
|
@@ -458,12 +458,15 @@ async function main() {
|
|
|
458
458
|
let bannerData = null;
|
|
459
459
|
if (await isHostBannerEnabled(projectRoot)) {
|
|
460
460
|
bannerData = await emitHostBanner(projectRoot);
|
|
461
|
-
// Always-on nudge: user
|
|
462
|
-
//
|
|
463
|
-
//
|
|
461
|
+
// Always-on nudge: a user decision has three legitimate forms and AUQ-001
|
|
462
|
+
// routes between them in order — operator verb first (nothing is blocked
|
|
463
|
+
// while the operator picks his moment), then derive-and-report from Session
|
|
464
|
+
// Config / STATE.md / git, and only then the tool. The banner carries the
|
|
465
|
+
// ORDER, not an absolute; full routing + exceptions in
|
|
466
|
+
// .claude/rules/ask-via-tool.md.
|
|
464
467
|
try {
|
|
465
468
|
console.log(JSON.stringify({
|
|
466
|
-
systemMessage: '🎯
|
|
469
|
+
systemMessage: '🎯 Decide: operator verb (/go) > derive+report > AUQ if blocking (.claude/rules/ask-via-tool.md).',
|
|
467
470
|
}));
|
|
468
471
|
} catch { /* best effort */ }
|
|
469
472
|
}
|
package/hooks/on-stop.mjs
CHANGED
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Part of v3.0.0 Windows-native migration. Issue #141.
|
|
10
10
|
*
|
|
11
|
-
* Exit codes: 0 always (informational hooks must never block)
|
|
11
|
+
* Exit codes: 0 always (informational hooks must never block) — including when
|
|
12
|
+
* node_modules is absent: zx is imported lazily and a missing package degrades
|
|
13
|
+
* to one rate-limited stderr line instead of an ERR_MODULE_NOT_FOUND stack on
|
|
14
|
+
* every turn end (GH Kanevry/session-orchestrator#63).
|
|
12
15
|
*
|
|
13
16
|
* JSONL format (`.orchestrator/metrics/events.jsonl`) — emitted via the canonical
|
|
14
17
|
* `emitEvent()` so the JSONL record and the optional Clank webhook always carry the
|
|
@@ -18,8 +21,7 @@
|
|
|
18
21
|
*/
|
|
19
22
|
|
|
20
23
|
import path from 'node:path';
|
|
21
|
-
import { promises as fs } from 'node:fs';
|
|
22
|
-
import { $ } from 'zx';
|
|
24
|
+
import { promises as fs, statSync, writeFileSync } from 'node:fs';
|
|
23
25
|
|
|
24
26
|
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
25
27
|
// #211: exit 0 immediately (silent allow) when this hook is disabled via profile/env
|
|
@@ -27,7 +29,7 @@ if (!shouldRunHook('on-stop')) process.exit(0);
|
|
|
27
29
|
|
|
28
30
|
import { emitEvent } from '../scripts/lib/events.mjs';
|
|
29
31
|
import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
|
|
30
|
-
import {
|
|
32
|
+
import { heartbeat, logSweepEvent } from '../scripts/lib/session-registry.mjs';
|
|
31
33
|
import { updateHeartbeat } from '../scripts/lib/session-lock.mjs';
|
|
32
34
|
|
|
33
35
|
// ---------------------------------------------------------------------------
|
|
@@ -87,17 +89,87 @@ function discriminate(input) {
|
|
|
87
89
|
return 'stop';
|
|
88
90
|
}
|
|
89
91
|
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// dependency degradation (GH Kanevry/session-orchestrator#63)
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
//
|
|
96
|
+
// zx is loaded LAZILY. A static `import { $ } from 'zx'` fails at MODULE LOAD
|
|
97
|
+
// time when node_modules is absent (interrupted install, EPERM sandbox, half-
|
|
98
|
+
// synced plugin cache), so the harness prints a 10-frame ERR_MODULE_NOT_FOUND
|
|
99
|
+
// stack on EVERY turn end with no hint that `npm install` is the fix. This
|
|
100
|
+
// mirrors the missing-`node` degradation in hooks/run-node.sh (§5): one
|
|
101
|
+
// actionable stderr line per 6h window, then carry on with reduced features.
|
|
102
|
+
|
|
103
|
+
/** Rate-limit window for the dependencies-missing warning — mirrors run-node.sh's 6h TTL. */
|
|
104
|
+
const DEP_WARN_TTL_MS = 6 * 60 * 60 * 1000;
|
|
105
|
+
|
|
106
|
+
/** Plugin root (the directory that owns package.json / node_modules). */
|
|
107
|
+
const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Marker path for the dependencies-missing warning. Deliberately a DIFFERENT
|
|
111
|
+
* name from run-node.sh's `session-orchestrator-node-missing-*`: sharing one
|
|
112
|
+
* marker would let a missing-node warning mask a missing-deps warning (and
|
|
113
|
+
* vice versa), leaving the operator with half a diagnostic.
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
function depWarnMarkerPath() {
|
|
117
|
+
// `||` alone falls back only on falsy values — a whitespace-only TMPDIR is
|
|
118
|
+
// truthy and would yield a garbage path (see .claude/rules/development.md).
|
|
119
|
+
const tmpDir = (process.env.TMPDIR || '').trim() || '/tmp';
|
|
120
|
+
const user = (process.env.USER || '').trim() || 'uid';
|
|
121
|
+
return path.join(tmpDir, `session-orchestrator-deps-missing-${user}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Print ONE actionable stderr line telling the operator to run `npm install`,
|
|
126
|
+
* at most once per DEP_WARN_TTL_MS. Marker mtime is the clock, exactly like
|
|
127
|
+
* run-node.sh's `find -mmin +360` check. Best-effort throughout: a marker we
|
|
128
|
+
* cannot stat is treated as expired (warn), a marker we cannot write means the
|
|
129
|
+
* next invocation warns again — noisier, never silent-broken.
|
|
130
|
+
*/
|
|
131
|
+
function warnDependenciesMissingOnce() {
|
|
132
|
+
const marker = depWarnMarkerPath();
|
|
133
|
+
try {
|
|
134
|
+
if (Date.now() - statSync(marker).mtimeMs < DEP_WARN_TTL_MS) return;
|
|
135
|
+
} catch { /* missing / unreadable marker → treat as expired */ }
|
|
136
|
+
try { writeFileSync(marker, ''); } catch { /* best-effort */ }
|
|
137
|
+
process.stderr.write(
|
|
138
|
+
`session-orchestrator: dependencies missing — run 'npm install' in ${PLUGIN_ROOT}. `
|
|
139
|
+
+ 'Hook features degraded (this warning is rate-limited to once per 6h).\n',
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Load zx's `$` lazily. Returns null when the package is not installed (after
|
|
145
|
+
* emitting the rate-limited advisory). Any OTHER import failure is re-thrown —
|
|
146
|
+
* a corrupt zx install is not a missing-dependency problem and must not be
|
|
147
|
+
* mislabelled as one.
|
|
148
|
+
* @returns {Promise<Function|null>}
|
|
149
|
+
*/
|
|
150
|
+
async function loadZx() {
|
|
151
|
+
try {
|
|
152
|
+
return (await import('zx')).$;
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (err?.code !== 'ERR_MODULE_NOT_FOUND') throw err;
|
|
155
|
+
warnDependenciesMissingOnce();
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
90
160
|
// ---------------------------------------------------------------------------
|
|
91
161
|
// git helpers
|
|
92
162
|
// ---------------------------------------------------------------------------
|
|
93
163
|
|
|
94
164
|
/**
|
|
95
165
|
* Returns { commit, branch } from the git repo at projectRoot, or null values
|
|
96
|
-
* if git is unavailable or the directory is not a git repo.
|
|
166
|
+
* if git is unavailable, zx is not installed, or the directory is not a git repo.
|
|
97
167
|
* @param {string} projectRoot — working directory for git commands
|
|
98
168
|
* @returns {Promise<{commit:string|null, branch:string|null}>}
|
|
99
169
|
*/
|
|
100
170
|
async function gitInfo(projectRoot) {
|
|
171
|
+
const $ = await loadZx();
|
|
172
|
+
if ($ === null) return { commit: null, branch: null };
|
|
101
173
|
$.verbose = false;
|
|
102
174
|
$.quiet = true;
|
|
103
175
|
const opts = projectRoot ? { cwd: projectRoot } : {};
|
|
@@ -180,16 +252,59 @@ async function handleStop(input) {
|
|
|
180
252
|
|
|
181
253
|
const sessionId = await resolveSessionId(input, projectRoot);
|
|
182
254
|
|
|
183
|
-
// v3.1.0 multi-session registry (#169) —
|
|
184
|
-
// entry
|
|
185
|
-
//
|
|
255
|
+
// v3.1.0 multi-session registry (#169), corrected in #1047 — REFRESH the
|
|
256
|
+
// registry entry here; never remove it.
|
|
257
|
+
//
|
|
258
|
+
// Stop fires at TURN end, not session end (see the file docblock). The
|
|
259
|
+
// original #169 wiring called deregisterSelf() here, so every assistant turn
|
|
260
|
+
// deleted this session's registry entry while the session was still live:
|
|
261
|
+
// measured on this host as 1 surviving entry (dead PID) against 12 live
|
|
262
|
+
// sockets, with sweep.log recording deletions of sessions aged 72/335/351/369
|
|
263
|
+
// minutes. Epic #583 fixed exactly this class for `.orchestrator/session.lock`
|
|
264
|
+
// (release → updateHeartbeat, below); the host registry never got the same
|
|
265
|
+
// correction. Deregistration now lives in hooks/on-session-end.mjs, which
|
|
266
|
+
// fires at the real end of the session.
|
|
267
|
+
//
|
|
268
|
+
// CODEX CAVEAT — stated here because THIS file runs on Codex and the file
|
|
269
|
+
// that owns deregistration does not. `hooks-codex.json` wires SessionStart +
|
|
270
|
+
// Stop but no SessionEnd (the Codex contract rejects the event), so on that
|
|
271
|
+
// bridge a session registers and never deregisters: its entry persists until
|
|
272
|
+
// sweepZombies() reaps it at the next SessionStart, up to `thresholdMin`
|
|
273
|
+
// (default 60 min). Accepted deliberately — it is the same path crash and
|
|
274
|
+
// Ctrl-C already take on every platform, and it is safe precisely BECAUSE
|
|
275
|
+
// the heartbeat below now advances, so a live session never ages into the
|
|
276
|
+
// sweep. Do NOT add a platform-detecting deregister branch here; that is the
|
|
277
|
+
// two-teardown-paths shape Epic #583 removed from the lock.
|
|
278
|
+
// (pi is unaffected: hooks-pi.json maps session_shutdown to on-session-end.mjs.
|
|
279
|
+
// Cursor is unaffected: it wires no SessionStart, so it never registers.)
|
|
280
|
+
//
|
|
281
|
+
// Failures are logged to sweep.log for observability but never re-thrown
|
|
282
|
+
// (hook must remain silent and non-blocking).
|
|
186
283
|
if (sessionId) {
|
|
187
284
|
try {
|
|
188
|
-
|
|
285
|
+
// heartbeat() returns null when no entry exists — a SILENT no-op that
|
|
286
|
+
// would otherwise make the loss permanent for the rest of the session
|
|
287
|
+
// (e.g. after a zombie sweep, or a harness UUID rotation with no fresh
|
|
288
|
+
// SessionStart). We do NOT re-register here: this hook has no access to
|
|
289
|
+
// the entry's platform / mode / host_class, and a re-registration would
|
|
290
|
+
// reset started_at to now — fabricating a session age instead of
|
|
291
|
+
// reporting one. Emit an observability breadcrumb instead, so the miss
|
|
292
|
+
// is visible in sweep.log rather than invisible. One line per turn while
|
|
293
|
+
// the entry is absent; that volume IS the signal, and the next
|
|
294
|
+
// SessionStart's registerSelf() ends it.
|
|
295
|
+
const refreshed = await heartbeat(sessionId);
|
|
296
|
+
if (refreshed === null) {
|
|
297
|
+
logSweepEvent({
|
|
298
|
+
event: 'heartbeat-missing',
|
|
299
|
+
session_id: sessionId,
|
|
300
|
+
error: 'no registry entry to refresh at turn end',
|
|
301
|
+
});
|
|
302
|
+
}
|
|
189
303
|
} catch (err) {
|
|
190
|
-
//
|
|
191
|
-
// Do NOT throw, do NOT write to stderr: the hook is
|
|
192
|
-
|
|
304
|
+
// Refresh failed at the fs layer — emit an observability breadcrumb to
|
|
305
|
+
// sweep.log. Do NOT throw, do NOT write to stderr: the hook is
|
|
306
|
+
// informational-only.
|
|
307
|
+
logSweepEvent({ event: 'heartbeat-failed', session_id: sessionId, error: err?.message ?? String(err) });
|
|
193
308
|
}
|
|
194
309
|
|
|
195
310
|
// Epic #583 W5-F1c — refresh session.lock heartbeat on every turn-end.
|
|
@@ -154,6 +154,13 @@ import { readFileSync, mkdirSync, writeFileSync, renameSync, realpathSync, statS
|
|
|
154
154
|
import { readStdin, writeStdoutLineSync } from '../scripts/lib/io.mjs';
|
|
155
155
|
import { resolveProjectDir } from '../scripts/lib/platform.mjs';
|
|
156
156
|
import { findScopeFile, pathMatchesPattern } from '../scripts/lib/hardening.mjs';
|
|
157
|
+
// #1057 — `sessionAgeMs` and its private `clockAgeMs` helper MOVED to the lib so
|
|
158
|
+
// hooks/enforce-scope.mjs can read the same session clock without a hook->hook
|
|
159
|
+
// import. Re-exported below, so this hook's public surface — and
|
|
160
|
+
// tests/hooks/post-bash-write-verify.test.mjs, which imports the named export —
|
|
161
|
+
// is unchanged. Two byte-identical copies of a clock is exactly the one-fact-two-
|
|
162
|
+
// copies class this repo keeps paying for.
|
|
163
|
+
import { sessionAgeMs } from '../scripts/lib/scope-gate.mjs';
|
|
157
164
|
|
|
158
165
|
// ---------------------------------------------------------------------------
|
|
159
166
|
// Constants
|
|
@@ -538,30 +545,6 @@ function readDirtyPaths(repoRoot) {
|
|
|
538
545
|
}
|
|
539
546
|
}
|
|
540
547
|
|
|
541
|
-
/**
|
|
542
|
-
* Age in ms of one session clock: a JSON file carrying an ISO start timestamp.
|
|
543
|
-
*
|
|
544
|
-
* Never throws. Absent / unparseable / non-string / non-ISO ⇒ null. A NEGATIVE
|
|
545
|
-
* age (timestamp in the future) is also null rather than a negative number —
|
|
546
|
-
* see `sessionAgeMs` for why that matters once two clocks are combined.
|
|
547
|
-
*
|
|
548
|
-
* @param {string} file absolute path to the JSON file
|
|
549
|
-
* @param {string} field name of the ISO-timestamp property
|
|
550
|
-
* @param {number} now
|
|
551
|
-
* @returns {number|null}
|
|
552
|
-
*/
|
|
553
|
-
function clockAgeMs(file, field, now) {
|
|
554
|
-
try {
|
|
555
|
-
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
556
|
-
const startedAt = Date.parse(parsed?.[field]);
|
|
557
|
-
if (!Number.isFinite(startedAt)) return null;
|
|
558
|
-
const age = now - startedAt;
|
|
559
|
-
return age >= 0 ? age : null;
|
|
560
|
-
} catch {
|
|
561
|
-
return null;
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
|
|
565
548
|
/**
|
|
566
549
|
* Milliseconds since the current session started, or null when unknown — the
|
|
567
550
|
* MINIMUM of two independently-written session clocks (#957 finding 2):
|
|
@@ -634,14 +617,7 @@ function clockAgeMs(file, field, now) {
|
|
|
634
617
|
* @param {number} [now]
|
|
635
618
|
* @returns {number|null}
|
|
636
619
|
*/
|
|
637
|
-
export
|
|
638
|
-
const dir = path.join(repoRoot, '.orchestrator');
|
|
639
|
-
const ages = [
|
|
640
|
-
clockAgeMs(path.join(dir, 'current-session.json'), 'timestamp', now),
|
|
641
|
-
clockAgeMs(path.join(dir, 'session.lock'), 'started_at', now),
|
|
642
|
-
].filter((age) => age !== null);
|
|
643
|
-
return ages.length > 0 ? Math.min(...ages) : null;
|
|
644
|
-
}
|
|
620
|
+
export { sessionAgeMs };
|
|
645
621
|
|
|
646
622
|
/** @returns {object|null} */
|
|
647
623
|
function readSnapshot(file) {
|
|
@@ -73,6 +73,7 @@ if (!shouldRunHook('pre-bash-destructive-guard')) process.exit(0);
|
|
|
73
73
|
/** @type {typeof import('../scripts/lib/io.mjs').readStdin} */ let readStdin;
|
|
74
74
|
/** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow;
|
|
75
75
|
/** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny;
|
|
76
|
+
/** @type {typeof import('../scripts/lib/io.mjs').emitWarn} */ let emitWarn;
|
|
76
77
|
let resolveProjectDir;
|
|
77
78
|
let resolvePluginRoot;
|
|
78
79
|
/**
|
|
@@ -81,10 +82,10 @@ let resolvePluginRoot;
|
|
|
81
82
|
* primitives).
|
|
82
83
|
*
|
|
83
84
|
* Held as ONE object rather than destructured into six bindings on purpose: the
|
|
84
|
-
* required-export list then exists in exactly one place —
|
|
85
|
-
* `
|
|
86
|
-
*
|
|
87
|
-
*
|
|
85
|
+
* required-export list then exists in exactly one place — the `requires` array
|
|
86
|
+
* on the `blocker` spec passed to `armGuard` (#993), which validates both the
|
|
87
|
+
* working-tree and the HEAD copy against it. The previous split (six names
|
|
88
|
+
* destructured here, two of them checked in the loader) is what let a HEAD copy
|
|
88
89
|
* missing four exports banner "DEGRADED — still armed" and then fail open on
|
|
89
90
|
* every command.
|
|
90
91
|
*
|
|
@@ -96,8 +97,38 @@ let loadEffectivePolicy;
|
|
|
96
97
|
let isSessionConfigHeading;
|
|
97
98
|
let emitEvent;
|
|
98
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Labels of guard modules that loaded from HEAD rather than the working tree
|
|
102
|
+
* (#993/#995). Populated by `bootstrap()` from `armGuard`'s return; read by
|
|
103
|
+
* `main()`, which pushes a visible-channel DEGRADED notice when it is non-empty —
|
|
104
|
+
* the stderr degradation banner alone is invisible under the exit-0 protocol.
|
|
105
|
+
*
|
|
106
|
+
* @type {string[]}
|
|
107
|
+
*/
|
|
108
|
+
let degradedLabels = [];
|
|
109
|
+
|
|
99
110
|
const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
|
|
100
111
|
|
|
112
|
+
/** This hook's name — threaded into both guard banners (#993: no hard-wired literal). */
|
|
113
|
+
const HOOK_NAME = 'pre-bash-destructive-guard';
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The two consequence blocks, spliced VERBATIM into the DEGRADED and GUARD
|
|
117
|
+
* INACTIVE banners (#993). Byte-identical to the pre-#993 inline banner text so
|
|
118
|
+
* the #992 banner-visibility tests stay green.
|
|
119
|
+
*/
|
|
120
|
+
const GUARD_CONSEQUENCE = {
|
|
121
|
+
degraded: [
|
|
122
|
+
' Consequence: destructive-command enforcement IS still armed, but it is evaluating the',
|
|
123
|
+
' COMMITTED (HEAD) command lexer — any uncommitted change to that file is NOT in effect.',
|
|
124
|
+
],
|
|
125
|
+
inactive: [
|
|
126
|
+
' Consequence: destructive Bash commands (git reset --hard, rm -rf, git push --force,',
|
|
127
|
+
' git stash, redirect-truncate of protected artefacts) are NOT being blocked. This is a',
|
|
128
|
+
' BROKEN GUARD, not a policy decision — do not route around it, repair it.',
|
|
129
|
+
],
|
|
130
|
+
};
|
|
131
|
+
|
|
101
132
|
/**
|
|
102
133
|
* Project dir for banner keying, resolved WITHOUT `platform.mjs` — that module
|
|
103
134
|
* is one of the ones that may have failed to load.
|
|
@@ -117,22 +148,76 @@ function bannerProjectDir() {
|
|
|
117
148
|
* @returns {Promise<void>}
|
|
118
149
|
*/
|
|
119
150
|
async function bootstrap() {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
151
|
+
const lib = (...seg) => pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', ...seg)).href;
|
|
152
|
+
|
|
153
|
+
const { armGuard } = await import('./_lib/guard-source-loader.mjs');
|
|
154
|
+
// #993: one generic loader for every repo dependency. Only `blocker` opts into
|
|
155
|
+
// the `git show HEAD:` fallback (dependency-free, so data:-URL loadable) and
|
|
156
|
+
// carries the COMPLETE required-export set — validated on the working-tree AND
|
|
157
|
+
// the HEAD copy, so a partial namespace banners GUARD INACTIVE rather than
|
|
158
|
+
// arming a guard that fails open per command. A load failure of any entry
|
|
159
|
+
// throws; the caller banners GUARD INACTIVE.
|
|
160
|
+
const { modules, degraded } = await armGuard(
|
|
161
|
+
{
|
|
162
|
+
io: { specifier: lib('io.mjs') },
|
|
163
|
+
platform: { specifier: lib('platform.mjs') },
|
|
164
|
+
config: { specifier: lib('config.mjs') },
|
|
165
|
+
blockedCommandsPolicy: { specifier: lib('blocked-commands-policy.mjs') },
|
|
166
|
+
sectionExtractor: { specifier: lib('config', 'section-extractor.mjs') },
|
|
167
|
+
events: { specifier: lib('events.mjs') },
|
|
168
|
+
blocker: {
|
|
169
|
+
specifier: lib('command-blocker.mjs'),
|
|
170
|
+
headFallback: true,
|
|
171
|
+
requires: [
|
|
172
|
+
'tokenizeCommand',
|
|
173
|
+
'commandMatchesBlocked',
|
|
174
|
+
'extractRedirectTargets',
|
|
175
|
+
'redirectRuleMatches',
|
|
176
|
+
'redirectSpanEnd',
|
|
177
|
+
'resolveSegmentVerb',
|
|
178
|
+
'splitChainSegments',
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
hookName: HOOK_NAME,
|
|
184
|
+
repoRoot: PLUGIN_ROOT,
|
|
185
|
+
projectDir: bannerProjectDir(),
|
|
186
|
+
consequence: GUARD_CONSEQUENCE,
|
|
187
|
+
}
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
|
|
191
|
+
({ resolveProjectDir, resolvePluginRoot } = modules.platform);
|
|
192
|
+
({ readConfigFile } = modules.config);
|
|
193
|
+
({ loadEffectivePolicy } = modules.blockedCommandsPolicy);
|
|
194
|
+
({ isSessionConfigHeading } = modules.sectionExtractor);
|
|
195
|
+
({ emitEvent } = modules.events);
|
|
196
|
+
blocker = modules.blocker;
|
|
197
|
+
degradedLabels = degraded;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Flush the aggregated allow-with-notice channel (#995).
|
|
202
|
+
*
|
|
203
|
+
* Warnings raised during rule evaluation accumulate in `notices` and are emitted
|
|
204
|
+
* ONCE, here, on the VISIBLE stdout channel via `emitWarn` (allow-with-notice) —
|
|
205
|
+
* else a plain `emitAllow`. Both exit 0 and never return, so this is always the
|
|
206
|
+
* last statement on an allow path.
|
|
207
|
+
*
|
|
208
|
+
* Why aggregate instead of `emitWarn`-ing inline at each warn site: `emitWarn`
|
|
209
|
+
* is `@returns never`, so an inline call mid-loop would exit BEFORE a later
|
|
210
|
+
* `block` rule was evaluated — flipping a would-be DENY into an ALLOW-with-notice
|
|
211
|
+
* (measured: `bash <33×-c>` overshoots the recursion cap, and a command that
|
|
212
|
+
* both trips the cap AND carries `rm -rf src/` would wave the delete through).
|
|
213
|
+
* A block, when it fires, exits via `emitDeny` and these notices are simply
|
|
214
|
+
* dropped: DENY wins, and the stderr copies of each notice remain for CI/debug.
|
|
215
|
+
*
|
|
216
|
+
* @param {string[]} notices
|
|
217
|
+
* @returns {never}
|
|
218
|
+
*/
|
|
219
|
+
function flushNotices(notices) {
|
|
220
|
+
return notices.length > 0 ? emitWarn(notices.join('\n')) : emitAllow();
|
|
136
221
|
}
|
|
137
222
|
|
|
138
223
|
// Module-level per-path policy cache (issue #250, extended for the #972
|
|
@@ -242,29 +327,6 @@ async function isGitStashNonEmpty(projectDir) {
|
|
|
242
327
|
}
|
|
243
328
|
}
|
|
244
329
|
|
|
245
|
-
/**
|
|
246
|
-
* Given a redirect token at `segment[i]`, return the index of the LAST token
|
|
247
|
-
* belonging to that redirect (the operand word, when one exists). Redirect
|
|
248
|
-
* operators and their operands name IO targets, not command arguments —
|
|
249
|
-
* `rm -rf /tmp/ok > out.log` must not collect `>` or `out.log` as rm targets
|
|
250
|
-
* (#983 FP fix). `dup` (`2>&1`) carries no operand word.
|
|
251
|
-
*
|
|
252
|
-
* @param {Array<{ text: string, quoted: boolean, redirect?: object }>} segment
|
|
253
|
-
* @param {number} i — index of the redirect token
|
|
254
|
-
* @returns {number}
|
|
255
|
-
*/
|
|
256
|
-
function redirectSpanEnd(segment, i) {
|
|
257
|
-
const tok = segment[i];
|
|
258
|
-
// `dup` (2>&1) carries its target inline; `heredoc` consumes its delimiter in
|
|
259
|
-
// the lexer (the body arrives as a separate QUOTED token) — neither owns an
|
|
260
|
-
// operand word, and claiming one would swallow the next real token.
|
|
261
|
-
const hasOperandWord = tok.redirect.mode !== 'dup' && tok.redirect.mode !== 'heredoc';
|
|
262
|
-
if (hasOperandWord && i + 1 < segment.length && !segment[i + 1].redirect) {
|
|
263
|
-
return i + 1;
|
|
264
|
-
}
|
|
265
|
-
return i;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
330
|
/**
|
|
269
331
|
* Parse ALL non-flag path arguments from every `rm` invocation in a command.
|
|
270
332
|
*
|
|
@@ -314,7 +376,7 @@ function parseRmTargets(command) {
|
|
|
314
376
|
for (let i = index + 1; i < segment.length; i++) {
|
|
315
377
|
const tok = segment[i];
|
|
316
378
|
if (tok.redirect) {
|
|
317
|
-
const end = redirectSpanEnd(segment, i);
|
|
379
|
+
const end = blocker.redirectSpanEnd(segment, i);
|
|
318
380
|
const mode = tok.redirect.mode;
|
|
319
381
|
if ((mode === 'truncate' || mode === 'append') && end > i) {
|
|
320
382
|
const operand = segment[end];
|
|
@@ -388,7 +450,7 @@ function commandHasRecursiveForceRm(command) {
|
|
|
388
450
|
let seenDashDash = false;
|
|
389
451
|
for (let i = index + 1; i < segment.length; i++) {
|
|
390
452
|
const t = segment[i];
|
|
391
|
-
if (t.redirect) { i = redirectSpanEnd(segment, i); continue; }
|
|
453
|
+
if (t.redirect) { i = blocker.redirectSpanEnd(segment, i); continue; }
|
|
392
454
|
if (!seenDashDash && t.text === '--') { seenDashDash = true; continue; }
|
|
393
455
|
if (!seenDashDash && !t.quoted && t.text.startsWith('-') && t.text !== '-') {
|
|
394
456
|
if (t.text === '--recursive') recursive = true;
|
|
@@ -675,6 +737,22 @@ async function main() {
|
|
|
675
737
|
const projectDir = resolveProjectDir();
|
|
676
738
|
const sessionId = resolveSessionId(input);
|
|
677
739
|
|
|
740
|
+
// #995 — aggregated allow-with-notice channel. Warn-severity matches and
|
|
741
|
+
// fail-visible markers accumulate here (stderr copies are kept for parity) and
|
|
742
|
+
// flush ONCE, on the visible stdout channel, only after EVERY rule has been
|
|
743
|
+
// evaluated. See flushNotices for why inline emitWarn would fail open.
|
|
744
|
+
const notices = [];
|
|
745
|
+
// A guard armed from HEAD (working-tree module unparseable) is a visible-channel
|
|
746
|
+
// concern too: the DEGRADED banner rides stderr only, which exit-0 discards. The
|
|
747
|
+
// stderr banner already fired once-per-session inside armGuard; surface it on
|
|
748
|
+
// stdout so a degraded ALLOW is not silently indistinguishable from a healthy one.
|
|
749
|
+
if (degradedLabels.length > 0) {
|
|
750
|
+
notices.push(
|
|
751
|
+
`${HOOK_NAME}: DEGRADED — guard module(s) loaded from HEAD, not your working tree ` +
|
|
752
|
+
`(${degradedLabels.join(', ')}); uncommitted changes to them are NOT in effect. See #992.`
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
|
|
678
756
|
// G3 — bypass: allow-destructive-ops: true in Session Config
|
|
679
757
|
// Note: parseSessionConfig only returns known fields; allow-destructive-ops is
|
|
680
758
|
// a new field, so we parse the raw markdown for it directly.
|
|
@@ -717,7 +795,10 @@ async function main() {
|
|
|
717
795
|
for (const warning of warnings) {
|
|
718
796
|
process.stderr.write(`⚠ pre-bash-destructive-guard: ${warning}\n`);
|
|
719
797
|
}
|
|
720
|
-
|
|
798
|
+
// No usable policy anywhere → documented total fail-open. Flush any notice
|
|
799
|
+
// already queued (a DEGRADED head-fallback notice), else a plain allow: the
|
|
800
|
+
// policy-load warnings above stay stderr-only, as they always have.
|
|
801
|
+
if (!Array.isArray(rules)) return flushNotices(notices);
|
|
721
802
|
|
|
722
803
|
// G5 — rule evaluation
|
|
723
804
|
for (const rule of rules) {
|
|
@@ -748,15 +829,19 @@ async function main() {
|
|
|
748
829
|
const reasons = [
|
|
749
830
|
...new Set(unresolved.map((e) => e.reason ?? 'variable/substitution')),
|
|
750
831
|
].join(', ');
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
)
|
|
832
|
+
// #995 — the #988-T2 recursion-cap marker used to reach stderr only, so
|
|
833
|
+
// under exit-0 a budget-exhausted command (`bash <33×-c>` hiding
|
|
834
|
+
// `> CLAUDE.md`) allowed with an INVISIBLE notice. Aggregate onto the
|
|
835
|
+
// visible channel; keep the stderr copy for parity.
|
|
836
|
+
const msg = `pre-bash-destructive-guard: unresolved redirect target (${reasons}) — not matched (fail-visible)`;
|
|
837
|
+
process.stderr.write(`⚠ ${msg}\n`);
|
|
838
|
+
notices.push(msg);
|
|
754
839
|
}
|
|
755
840
|
if (!blocker.redirectRuleMatches(rule, command, { repoRoot: projectDir })) continue;
|
|
756
841
|
if (severity !== 'block') {
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
);
|
|
842
|
+
const msg = `pre-bash-destructive-guard: redirect target matched (rule: ${id}) — ${rationale}`;
|
|
843
|
+
process.stderr.write(`⚠ ${msg}\n`);
|
|
844
|
+
notices.push(msg); // #995 — visible on the allow-with-notice channel
|
|
760
845
|
continue;
|
|
761
846
|
}
|
|
762
847
|
// Reason stays short (stdout-budget): operator + target, never the command.
|
|
@@ -782,9 +867,9 @@ async function main() {
|
|
|
782
867
|
continue;
|
|
783
868
|
}
|
|
784
869
|
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
);
|
|
870
|
+
const msg = `pre-bash-destructive-guard: '${pattern}' (rule: ${id}) — ${rationale}`;
|
|
871
|
+
process.stderr.write(`⚠ ${msg}\n`);
|
|
872
|
+
notices.push(msg); // #995 — visible on the allow-with-notice channel
|
|
788
873
|
// Best-effort telemetry — must never affect the warn/allow outcome.
|
|
789
874
|
try {
|
|
790
875
|
await emitEvent('orchestrator.destructive_guard.warned', {
|
|
@@ -832,8 +917,9 @@ async function main() {
|
|
|
832
917
|
// Unknown severity → skip (conservative allow for unknown future severities)
|
|
833
918
|
}
|
|
834
919
|
|
|
835
|
-
// G6 — no blocking match
|
|
836
|
-
|
|
920
|
+
// G6 — no blocking match. Flush the aggregated allow-with-notice channel
|
|
921
|
+
// (#995): emitWarn if any notice queued (visible), else a silent allow.
|
|
922
|
+
return flushNotices(notices);
|
|
837
923
|
}
|
|
838
924
|
|
|
839
925
|
// ---------------------------------------------------------------------------
|
|
@@ -857,8 +943,9 @@ try {
|
|
|
857
943
|
const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
|
|
858
944
|
// Unthrottled by design: EVERY call in an unarmed session says so (#992
|
|
859
945
|
// hardening — the once-per-session marker it used to pass through was
|
|
860
|
-
// suppressible by a bare `touch` on a derivable tmp path).
|
|
861
|
-
|
|
946
|
+
// suppressible by a bare `touch` on a derivable tmp path). hookName is
|
|
947
|
+
// threaded explicitly (#993 — no hard-wired literal in the loader).
|
|
948
|
+
emitGuardInactiveBanner({ hookName: HOOK_NAME, error: loadError, consequence: GUARD_CONSEQUENCE });
|
|
862
949
|
} catch {
|
|
863
950
|
// Last resort: even the banner helper failed to load. Emit unconditionally
|
|
864
951
|
// (no once-per-session keying) — repeated noise beats a silent disarm.
|