session-orchestrator 3.17.0 → 3.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +185 -412
- package/README.md +12 -9
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +9 -4
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +794 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +245 -50
- package/docs/session-config-template.md +39 -26
- package/hooks/_lib/guard-source-loader.mjs +680 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +226 -19
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +30 -4
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +616 -164
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +6 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/emit-session.mjs +3 -40
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1273 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +128 -24
- package/scripts/lib/reconcile/engine.mjs +156 -54
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +568 -145
- package/scripts/lib/session-close-backfill.mjs +63 -8
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +244 -36
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +218 -16
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/release.mjs +534 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +117 -18
- package/skills/gitlab-ops/SKILL.md +25 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/grill/soul.md +44 -1
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/plan/soul.md +46 -3
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -36
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +32 -2
- package/skills/session-end/plan-verification.md +6 -7
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +75 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/session-start/soul.md +41 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +6 -7
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +198 -80
- package/templates/_shared/loop.md +4 -4
|
@@ -0,0 +1,1054 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pre-bash-sessions-ledger-guard.mjs — PreToolUse Bash hook: blocks a DIRECT
|
|
4
|
+
* shell write into the sessions ledger (`.orchestrator/metrics/sessions.jsonl`).
|
|
5
|
+
*
|
|
6
|
+
* ## Why this exists (GitLab #958, finding 3)
|
|
7
|
+
*
|
|
8
|
+
* W1/D4 proved the root cause of the malformed ledger record: it was composed
|
|
9
|
+
* by the coordinator from a Markdown template and appended straight to
|
|
10
|
+
* `sessions.jsonl`, bypassing `scripts/emit-session.mjs` — the validating
|
|
11
|
+
* writer that would have refused it. Piping that record into emit-session.mjs
|
|
12
|
+
* exits 1 and leaves the file untouched, so it cannot have taken the sanctioned
|
|
13
|
+
* path.
|
|
14
|
+
*
|
|
15
|
+
* `skills/session-end/session-metrics-write.md` already says, in prose:
|
|
16
|
+
* "Hand-composing JSON and appending it directly to `sessions.jsonl` is
|
|
17
|
+
* forbidden." A prose prohibition did not stop it. This hook is the mechanism.
|
|
18
|
+
*
|
|
19
|
+
* ## What is ALLOWED, and why it needs no name allowlist
|
|
20
|
+
*
|
|
21
|
+
* The guard fires on WRITE INTENT (`>`/`>>`, `tee`, `dd of=`, `cp`/`mv`
|
|
22
|
+
* destination), not on command names. The three sanctioned writers —
|
|
23
|
+
* `scripts/emit-session.mjs`, `scripts/backfill-sessions.mjs`,
|
|
24
|
+
* `scripts/migrate-sessions-jsonl.mjs`, `scripts/backfill-abandoned-sessions.mjs`
|
|
25
|
+
* — all open the file from inside Node (append / atomic tmp+rename); none of
|
|
26
|
+
* them is invoked through a shell redirect into the ledger. They are therefore
|
|
27
|
+
* allowed STRUCTURALLY: their command strings contain no ledger write-intent
|
|
28
|
+
* for the matcher to see.
|
|
29
|
+
*
|
|
30
|
+
* That is a deliberate design choice over a script-name allowlist. A name
|
|
31
|
+
* allowlist in a shell string is trivially spoofable — the name can sit in a
|
|
32
|
+
* comment (`echo '…' >> ledger # emit-session.mjs`), in an unrelated argument
|
|
33
|
+
* (`echo emit-session.mjs >> ledger`), or inside the quoted payload being
|
|
34
|
+
* appended. "Does this command redirect bytes into the ledger" is a property of
|
|
35
|
+
* the command's STRUCTURE that no amount of name-dropping can fake, and that
|
|
36
|
+
* the sanctioned writers structurally never have. There is nothing to spoof
|
|
37
|
+
* because there is no name being matched.
|
|
38
|
+
*
|
|
39
|
+
* The one true positive this costs is `node scripts/emit-session.mjs … >> …
|
|
40
|
+
* sessions.jsonl` — piping the writer's `{"action":"appended"}` receipt INTO
|
|
41
|
+
* the ledger. That is a bug, and denying it is correct.
|
|
42
|
+
*
|
|
43
|
+
* ## Path scope
|
|
44
|
+
*
|
|
45
|
+
* A write target counts as the ledger when its BASENAME is `sessions.jsonl`,
|
|
46
|
+
* in any directory — including a tmp-repo fixture. Not "every .jsonl": exactly
|
|
47
|
+
* the ledger's own filename. Matching the basename rather than the full
|
|
48
|
+
* `.orchestrator/metrics/sessions.jsonl` suffix closes the
|
|
49
|
+
* `cd .orchestrator/metrics && echo … >> sessions.jsonl` hole. Tmp ledgers are
|
|
50
|
+
* guarded too, at no cost: test fixtures write them from Node (`writeFileSync`),
|
|
51
|
+
* which this Bash-only hook never sees.
|
|
52
|
+
*
|
|
53
|
+
* ## How the command is read (GitLab #958 follow-up, security review 2×MED)
|
|
54
|
+
*
|
|
55
|
+
* The first cut used a redirect REGEX over the raw string plus a per-character
|
|
56
|
+
* "is this index quoted?" mask. Both halves leaked, and both leaks were
|
|
57
|
+
* demonstrated to write the ledger:
|
|
58
|
+
*
|
|
59
|
+
* - MED-1 — the mask modelled only `'…'` and `"…"`. One apostrophe from a
|
|
60
|
+
* shell COMMENT (`# don't forget the record`), a HERE-DOC body (`it's
|
|
61
|
+
* fine`) or an ANSI-C literal (`$'a\'b'`) left it stuck in "single" for the
|
|
62
|
+
* rest of the command, and the real `>>` after it was skipped. English
|
|
63
|
+
* prose is full of apostrophes, so `# don't …` is the EXPECTED shape of the
|
|
64
|
+
* accident this guard exists to catch, not an adversarial construction.
|
|
65
|
+
* - MED-2 — the regex captured ONE quoting run, so a target assembled from
|
|
66
|
+
* several (`"$PWD"/…/sessions.jsonl`, `.orchestrator/metrics/"sessions.jsonl"`,
|
|
67
|
+
* `My\ Repo/…/sessions.jsonl`, `"."/…/sessions.jsonl`) was captured wrong
|
|
68
|
+
* and its tail never examined. Two of those four contain no variable at all.
|
|
69
|
+
*
|
|
70
|
+
* Both classes are STATICALLY resolvable, so "a PreToolUse hook cannot know the
|
|
71
|
+
* target" never covered them. The regex + mask are gone. {@link scanCommand} is
|
|
72
|
+
* a single quote-aware pass that understands comments, here-doc bodies,
|
|
73
|
+
* `$'…'` / `$"…"`, backslash escapes, fd duplication (`2>&1` contributes no
|
|
74
|
+
* target) and multi-run words — it returns the RESOLVED redirect targets, so
|
|
75
|
+
* all four MED-2 forms reduce to the same basename. The same pass emits a
|
|
76
|
+
* sanitized command (comments and here-doc bodies removed, `$'…'` folded to a
|
|
77
|
+
* plain literal, redirect operators AND their targets elided) for the
|
|
78
|
+
* `tee`/`dd`/`cp`/`mv` matcher.
|
|
79
|
+
*
|
|
80
|
+
* ## Why this scanner is NOT redundant with `tokenizeCommand` (#965 follow-up)
|
|
81
|
+
*
|
|
82
|
+
* #965 taught the shared lexer comments, here-doc bodies and `$'…'`, which
|
|
83
|
+
* removes the ORIGINAL reason this pass existed. It is kept anyway, because two
|
|
84
|
+
* things it produces are structurally absent from `tokenizeCommand` — measured,
|
|
85
|
+
* not assumed:
|
|
86
|
+
*
|
|
87
|
+
* 1. **The `balanced` flag.** `tokenizeCommand('echo "x')` returns tokens
|
|
88
|
+
* shaped `{text, quoted}` — there is no third field, and its unterminated-
|
|
89
|
+
* quote path deliberately fails OPEN (flush + mark quoted) so a wedged
|
|
90
|
+
* lexer cannot block every Bash call. That is right for a lexer serving
|
|
91
|
+
* nine rules; it is wrong here, where `findLedgerWrite` must fail CLOSED.
|
|
92
|
+
* The signal is destroyed inside the lexer, so no consumer can recover it.
|
|
93
|
+
* 2. **Redirect-target elision.** `tokenizeCommand` emits the redirect TARGET
|
|
94
|
+
* as an ordinary token by design (its docblock: dropping it "would have
|
|
95
|
+
* silently changed rm-allowlist verdicts"). Feeding the raw command to the
|
|
96
|
+
* `cp`/`mv` branch therefore picks the wrong destination:
|
|
97
|
+
* `cp foo …/sessions.jsonl > /dev/null` resolves dest=`/dev/null` and
|
|
98
|
+
* ALLOWS, where the sanitized form resolves dest=`…/sessions.jsonl` and
|
|
99
|
+
* denies. Deleting the sanitizer re-opens a proven ledger write path.
|
|
100
|
+
*
|
|
101
|
+
* ## Wrapper + segment parsing is the LIB's, not a local copy (#991)
|
|
102
|
+
*
|
|
103
|
+
* This module used to carry three near-twins of shared machinery: a local
|
|
104
|
+
* `splitSegments`, a local `resolveVerb`, and a FLAGBLIND `VERB_PREFIXES` set.
|
|
105
|
+
* The copies drifted, and the drift was fail-OPEN: `VERB_PREFIXES` skipped only
|
|
106
|
+
* the wrapper WORD and never its arguments, so `sudo -u root tee -a <ledger>`
|
|
107
|
+
* resolved to the verb `-u`, `tee` was never in verb position, and the write
|
|
108
|
+
* was ALLOWED. All fourteen wrapper spellings in the test table were measured
|
|
109
|
+
* ALLOW before this change — the guard reported a safety it did not provide.
|
|
110
|
+
*
|
|
111
|
+
* They are gone. `splitChainSegments` and `resolveSegmentVerb` now come from
|
|
112
|
+
* `scripts/lib/command-blocker.mjs` — the same primitives
|
|
113
|
+
* hooks/pre-bash-destructive-guard.mjs consumes, imported DIRECTLY from the
|
|
114
|
+
* source module for the reason stated there: the `hardening.mjs` barrel
|
|
115
|
+
* deliberately does not re-export the #982/#983 primitives, and one import edge
|
|
116
|
+
* keeps this hook's dependency graph unambiguous. The deleted `VERB_PREFIXES`
|
|
117
|
+
* was a strict SUBSET of the lib's `WRAPPER_UNWRAP` table (6 ⊂ 9), so the
|
|
118
|
+
* switch ADDS `doas`/`timeout`/`stdbuf` plus flag-awareness and removes nothing.
|
|
119
|
+
*
|
|
120
|
+
* `resolveSegmentVerb` additionally reports `wrapperArgs` — the value-taking
|
|
121
|
+
* wrapper flags consumed on the way to the verb. One of those operands is
|
|
122
|
+
* itself a WRITE: `/usr/bin/time -o <file>` TRUNCATES `<file>` (BSD time(1):
|
|
123
|
+
* "If file exists and the -a flag is not specified, the file will be
|
|
124
|
+
* overwritten"), while the verb is whatever `time` goes on to run. The target
|
|
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.
|
|
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
|
|
132
|
+
* KEYWORD `time` is unaffected: it rejects the flag outright
|
|
133
|
+
* (`bash -c 'time -o x echo hi'` → "-o: command not found"), so only
|
|
134
|
+
* `/usr/bin/time`, `command time` and `env time` can carry it at all.
|
|
135
|
+
*
|
|
136
|
+
* Fail-CLOSED on an unbalanced quote: if the scan ends mid-quote the command is
|
|
137
|
+
* one bash itself would reject with a syntax error, so denying it when it
|
|
138
|
+
* mentions the ledger costs ~0 in false positives and removes "leave the lexer
|
|
139
|
+
* confused" as a bypass strategy. This is the one place the guard is closed
|
|
140
|
+
* rather than open, and it is closed because the alternative is a silent skip.
|
|
141
|
+
*
|
|
142
|
+
* ## What this does NOT catch (stated plainly — the bounds are the contract)
|
|
143
|
+
*
|
|
144
|
+
* - Indirection through a variable: `>> "$LEDGER"`, `tee "$LEDGER"`,
|
|
145
|
+
* `exec 3>"$LEDGER"; echo x >&3`. A PreToolUse hook sees the unexpanded
|
|
146
|
+
* string; the target is genuinely unknowable here.
|
|
147
|
+
* - A target whose BASENAME only exists after expansion:
|
|
148
|
+
* `>> $(echo sessions.jsonl)`, `>> "$dir/$name"`, a glob or brace form.
|
|
149
|
+
* What matters is whether the literal `sessions.jsonl` survives in the
|
|
150
|
+
* command string — the DIRECTORY half may expand freely and is still
|
|
151
|
+
* denied (`"$PWD"/…/sessions.jsonl`, `$HOME/…/sessions.jsonl`,
|
|
152
|
+
* `"$(pwd)/…/sessions.jsonl"` and `exec 3> …/sessions.jsonl` all deny;
|
|
153
|
+
* verified, not assumed).
|
|
154
|
+
* - A redirect nested inside a DOUBLE-QUOTED command substitution or a
|
|
155
|
+
* backtick run: `echo "$(echo x >> …/sessions.jsonl)"` allows, while the
|
|
156
|
+
* unquoted `echo $(echo x >> …/sessions.jsonl)` denies. The scanner
|
|
157
|
+
* consumes a quoted run whole and does not recurse into it. Verified, and
|
|
158
|
+
* left uncaught deliberately: recursing costs lexer surface, and a redirect
|
|
159
|
+
* buried in a quoted substitution is not the accident shape this guard is
|
|
160
|
+
* for — it is the "determined circumvention" line below.
|
|
161
|
+
* - In-place editors: `sed -i`, `perl -i`, `ed`, an interactive editor.
|
|
162
|
+
* - A write performed inside an interpreter: `node -e`, `python -c`,
|
|
163
|
+
* `bash -c '… >> …/sessions.jsonl'`, or any script the command invokes that
|
|
164
|
+
* appends the ledger itself. The one exception is a WRAPPER payload —
|
|
165
|
+
* `env -S 'tee -a …/sessions.jsonl'` — which `resolveSegmentVerb` reports
|
|
166
|
+
* as a payload and which this matcher re-enters (to MAX_PAYLOAD_DEPTH).
|
|
167
|
+
* Interpreter `-c` payloads are a larger surface and stay out of scope.
|
|
168
|
+
* - Obfuscation: `eval`, `base64 -d | sh`, a here-doc-fed shell. Here-doc
|
|
169
|
+
* BODIES are skipped as the data they are — `bash <<EOF … EOF` therefore
|
|
170
|
+
* hides its payload from this matcher by construction.
|
|
171
|
+
* - `sponge`, `install`, `rsync`, `awk > file` and other less common write
|
|
172
|
+
* verbs (a redirect inside an `awk` program string is quoted data here).
|
|
173
|
+
* - The Write/Edit tools — a different PreToolUse matcher entirely
|
|
174
|
+
* (`hooks/enforce-scope.mjs` territory), not this hook's surface.
|
|
175
|
+
*
|
|
176
|
+
* This is a guard against the accident that actually happened, not a
|
|
177
|
+
* containment boundary. Determined circumvention is out of scope by design.
|
|
178
|
+
*
|
|
179
|
+
* ## Fail-open on internal error
|
|
180
|
+
*
|
|
181
|
+
* A crash allows the command (exit 0, empty stdout) and warns on stderr,
|
|
182
|
+
* matching every sibling Bash guard in this repo. This is a nudge on a
|
|
183
|
+
* developer machine, not a security boundary: a wedged guard that blocks every
|
|
184
|
+
* Bash call is strictly worse than a missed enforcement, and the visibility
|
|
185
|
+
* half of #958 (the session-start ledger-integrity banner) catches what slips
|
|
186
|
+
* through. Under the #906 exit-0 protocol, "fail-open" is literally exit 0 with
|
|
187
|
+
* no stdout envelope — the harness reads no decision and proceeds.
|
|
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
|
+
*
|
|
198
|
+
* ## Override
|
|
199
|
+
*
|
|
200
|
+
* SO_DISABLED_HOOKS=pre-bash-sessions-ledger-guard (session-level)
|
|
201
|
+
* SO_HOOK_PROFILE=minimal|off
|
|
202
|
+
*
|
|
203
|
+
* Deliberately session-level and not in-command: an in-command escape hatch
|
|
204
|
+
* would be one more string for a hurried agent to paste, i.e. the discipline
|
|
205
|
+
* failure this hook exists to replace.
|
|
206
|
+
*/
|
|
207
|
+
|
|
208
|
+
import { spawnSync } from 'node:child_process';
|
|
209
|
+
import path from 'node:path';
|
|
210
|
+
import { pathToFileURL } from 'node:url';
|
|
211
|
+
|
|
212
|
+
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
213
|
+
// #211: exit 0 immediately (silent allow) when this hook is disabled via profile/env
|
|
214
|
+
if (!shouldRunHook('pre-bash-sessions-ledger-guard')) process.exit(0);
|
|
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
|
+
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
// Constants
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
/** The ledger's filename. A write target matches on basename equality. */
|
|
348
|
+
const LEDGER_BASENAME = 'sessions.jsonl';
|
|
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
|
+
|
|
354
|
+
/**
|
|
355
|
+
* How much of the offending target may appear in the deny reason.
|
|
356
|
+
*
|
|
357
|
+
* Load-bearing, not cosmetic. `emitDeny` clamps the whole reason to
|
|
358
|
+
* DENY_REASON_MAX (#906) — so an unbounded target on the FIRST line pushes the
|
|
359
|
+
* `emit-session.mjs` route, the override and the issue reference off the end,
|
|
360
|
+
* and the operator gets a wall of characters with no instruction. Bounding the
|
|
361
|
+
* target keeps the reason short enough that the actionable half always survives.
|
|
362
|
+
*/
|
|
363
|
+
const TARGET_ECHO_MAX = 200;
|
|
364
|
+
|
|
365
|
+
/** Verbs whose LAST non-flag argument is a write destination. */
|
|
366
|
+
const DEST_LAST_VERBS = new Set(['cp', 'mv']);
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* How deep to follow a wrapper's command-string payload (`env -S '…'`).
|
|
370
|
+
*
|
|
371
|
+
* Payloads are the only recursion source here, and two levels covers every
|
|
372
|
+
* shape a hurried agent produces while keeping the work bounded — a cap that
|
|
373
|
+
* cannot be exhausted into a bypass because the OUTER command is still matched
|
|
374
|
+
* on its own terms.
|
|
375
|
+
*/
|
|
376
|
+
const MAX_PAYLOAD_DEPTH = 2;
|
|
377
|
+
|
|
378
|
+
/** Unquoted characters that end a shell WORD. */
|
|
379
|
+
const WORD_END = new Set([';', '|', '&', '<', '>', '(', ')', '\n']);
|
|
380
|
+
|
|
381
|
+
/** `>&1`, `>&2`, `>&-`, `>&3-` duplicate a descriptor — no file target. */
|
|
382
|
+
const FD_DUP_RE = /^(?:\d+-?|-)$/;
|
|
383
|
+
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
// Helpers
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Does this path string name the sessions ledger?
|
|
390
|
+
*
|
|
391
|
+
* Basename equality, so `.orchestrator/metrics/sessions.jsonl`,
|
|
392
|
+
* `/tmp/x/.orchestrator/metrics/sessions.jsonl` and a bare `sessions.jsonl`
|
|
393
|
+
* (after a `cd`) all match, while `sessions.jsonl.bak`, `learnings.jsonl` and
|
|
394
|
+
* `events.jsonl` do not.
|
|
395
|
+
*
|
|
396
|
+
* @param {string} target
|
|
397
|
+
* @returns {boolean}
|
|
398
|
+
*/
|
|
399
|
+
function refersToLedger(target) {
|
|
400
|
+
if (typeof target !== 'string' || target.length === 0) return false;
|
|
401
|
+
// Normalise Windows-style separators before taking the basename so a
|
|
402
|
+
// backslash-spelled path is not read as one long filename.
|
|
403
|
+
const normalized = target.replace(/\\/g, '/');
|
|
404
|
+
return path.posix.basename(normalized) === LEDGER_BASENAME;
|
|
405
|
+
}
|
|
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
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Skip a quoted run that OPENS at `open`.
|
|
550
|
+
*
|
|
551
|
+
* @param {string} command
|
|
552
|
+
* @param {number} open - index of the opening quote character
|
|
553
|
+
* @param {boolean} escapes - true when `\` escapes the next char (double / ANSI-C)
|
|
554
|
+
* @returns {number} index just past the closing quote, or -1 when never closed
|
|
555
|
+
*/
|
|
556
|
+
function skipQuotedRun(command, open, escapes) {
|
|
557
|
+
const quote = command[open];
|
|
558
|
+
for (let i = open + 1; i < command.length; i++) {
|
|
559
|
+
const ch = command[i];
|
|
560
|
+
if (escapes && ch === '\\' && i + 1 < command.length) { i++; continue; }
|
|
561
|
+
if (ch === quote) return i + 1;
|
|
562
|
+
}
|
|
563
|
+
return -1;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Read ONE shell word starting at `i`, resolving quoting and escapes to the
|
|
568
|
+
* logical value bash would pass as an argument.
|
|
569
|
+
*
|
|
570
|
+
* This is the MED-2 fix: a word is a sequence of runs, not a single one, so
|
|
571
|
+
* `"$PWD"/.orchestrator/metrics/sessions.jsonl`,
|
|
572
|
+
* `.orchestrator/metrics/"sessions.jsonl"`, `My\ Repo/…/sessions.jsonl` and
|
|
573
|
+
* `"."/…/sessions.jsonl` all resolve to a value whose basename is the ledger.
|
|
574
|
+
*
|
|
575
|
+
* @param {string} command
|
|
576
|
+
* @param {number} i
|
|
577
|
+
* @returns {{ value: string, end: number, balanced: boolean }}
|
|
578
|
+
*/
|
|
579
|
+
function readWord(command, i) {
|
|
580
|
+
let value = '';
|
|
581
|
+
let state = 'normal';
|
|
582
|
+
while (i < command.length) {
|
|
583
|
+
const ch = command[i];
|
|
584
|
+
|
|
585
|
+
if (state === 'single') {
|
|
586
|
+
if (ch === "'") { state = 'normal'; i++; continue; }
|
|
587
|
+
value += ch; i++; continue;
|
|
588
|
+
}
|
|
589
|
+
if (state === 'double' || state === 'ansi') {
|
|
590
|
+
if (ch === (state === 'double' ? '"' : "'")) { state = 'normal'; i++; continue; }
|
|
591
|
+
if (ch === '\\' && i + 1 < command.length) { value += command[i + 1]; i += 2; continue; }
|
|
592
|
+
value += ch; i++; continue;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (/\s/.test(ch) || WORD_END.has(ch)) break;
|
|
596
|
+
if (ch === '\\' && i + 1 < command.length) { value += command[i + 1]; i += 2; continue; }
|
|
597
|
+
if (ch === "'") { state = 'single'; i++; continue; }
|
|
598
|
+
if (ch === '"') { state = 'double'; i++; continue; }
|
|
599
|
+
if (ch === '$' && command[i + 1] === "'") { state = 'ansi'; i += 2; continue; }
|
|
600
|
+
if (ch === '$' && command[i + 1] === '"') { state = 'double'; i += 2; continue; }
|
|
601
|
+
value += ch; i++;
|
|
602
|
+
}
|
|
603
|
+
return { value, end: i, balanced: state === 'normal' };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Skip a here-doc body starting at `from`, returning the index just past the
|
|
608
|
+
* terminator line (or the end of input when the terminator never arrives).
|
|
609
|
+
*
|
|
610
|
+
* @param {string} command
|
|
611
|
+
* @param {number} from
|
|
612
|
+
* @param {string} delim
|
|
613
|
+
* @param {boolean} stripTabs - `<<-` form: leading tabs on the terminator ignored
|
|
614
|
+
* @returns {number}
|
|
615
|
+
*/
|
|
616
|
+
function skipHeredocBody(command, from, delim, stripTabs) {
|
|
617
|
+
let i = from;
|
|
618
|
+
while (i < command.length) {
|
|
619
|
+
let lineEnd = command.indexOf('\n', i);
|
|
620
|
+
if (lineEnd === -1) lineEnd = command.length;
|
|
621
|
+
const raw = command.slice(i, lineEnd);
|
|
622
|
+
const line = stripTabs ? raw.replace(/^\t+/, '') : raw;
|
|
623
|
+
i = lineEnd + 1;
|
|
624
|
+
if (line === delim) return Math.min(i, command.length);
|
|
625
|
+
}
|
|
626
|
+
return command.length;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* One quote-aware pass over the command.
|
|
631
|
+
*
|
|
632
|
+
* Returns every RESOLVED redirect target, plus a sanitized command for the
|
|
633
|
+
* write-verb matcher: comments and here-doc bodies removed, `$'…'` folded to a
|
|
634
|
+
* plain single-quoted literal, redirect operators and their targets elided.
|
|
635
|
+
* `balanced` is false when the scan ended inside an unterminated quote — a
|
|
636
|
+
* command bash would reject, and the fail-closed trigger (see docblock).
|
|
637
|
+
*
|
|
638
|
+
* @param {string} command
|
|
639
|
+
* @returns {{ targets: string[], sanitized: string, balanced: boolean }}
|
|
640
|
+
*/
|
|
641
|
+
function scanCommand(command) {
|
|
642
|
+
const targets = [];
|
|
643
|
+
const heredocs = [];
|
|
644
|
+
let out = '';
|
|
645
|
+
let i = 0;
|
|
646
|
+
|
|
647
|
+
const atWordStart = (idx) => idx === 0 || /[\s;|&()<>]/.test(command[idx - 1]);
|
|
648
|
+
|
|
649
|
+
while (i < command.length) {
|
|
650
|
+
const ch = command[i];
|
|
651
|
+
|
|
652
|
+
// A `#` in word position comments out the rest of the LINE. Without this,
|
|
653
|
+
// one apostrophe in English prose desynced the whole scan (MED-1).
|
|
654
|
+
if (ch === '#' && atWordStart(i)) {
|
|
655
|
+
while (i < command.length && command[i] !== '\n') i++;
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (ch === '\n') {
|
|
660
|
+
out += '\n';
|
|
661
|
+
i++;
|
|
662
|
+
while (heredocs.length) {
|
|
663
|
+
const { delim, stripTabs } = heredocs.shift();
|
|
664
|
+
i = skipHeredocBody(command, i, delim, stripTabs);
|
|
665
|
+
}
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (ch === '\\' && i + 1 < command.length) { out += ch + command[i + 1]; i += 2; continue; }
|
|
670
|
+
|
|
671
|
+
// ANSI-C `$'…'`: a backslash escapes the closing quote, which the old mask
|
|
672
|
+
// did not know (MED-1). Folded to a plain literal for the write-verb lexer.
|
|
673
|
+
if (ch === '$' && command[i + 1] === "'") {
|
|
674
|
+
const end = skipQuotedRun(command, i + 1, true);
|
|
675
|
+
if (end === -1) return { targets, sanitized: out, balanced: false };
|
|
676
|
+
const literal = command.slice(i + 2, end - 1).replace(/\\(.)/g, '$1').replace(/'/g, '');
|
|
677
|
+
out += `'${literal}'`;
|
|
678
|
+
i = end;
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
if (ch === '$' && command[i + 1] === '"') {
|
|
682
|
+
const end = skipQuotedRun(command, i + 1, true);
|
|
683
|
+
if (end === -1) return { targets, sanitized: out, balanced: false };
|
|
684
|
+
out += command.slice(i + 1, end);
|
|
685
|
+
i = end;
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
if (ch === "'" || ch === '"') {
|
|
689
|
+
const end = skipQuotedRun(command, i, ch === '"');
|
|
690
|
+
if (end === -1) return { targets, sanitized: out, balanced: false };
|
|
691
|
+
out += command.slice(i, end);
|
|
692
|
+
i = end;
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Here-doc: remember the delimiter now, skip the body at the next newline.
|
|
697
|
+
// `<<<` is a here-STRING, not a here-doc, and needs no body skipping.
|
|
698
|
+
if (ch === '<' && command[i + 1] === '<' && command[i + 2] !== '<') {
|
|
699
|
+
let j = i + 2;
|
|
700
|
+
let stripTabs = false;
|
|
701
|
+
if (command[j] === '-') { stripTabs = true; j++; }
|
|
702
|
+
while (command[j] === ' ' || command[j] === '\t') j++;
|
|
703
|
+
const w = readWord(command, j);
|
|
704
|
+
if (!w.balanced) return { targets, sanitized: out, balanced: false };
|
|
705
|
+
if (w.value) heredocs.push({ delim: w.value, stripTabs });
|
|
706
|
+
out += ' ';
|
|
707
|
+
i = Math.max(w.end, i + 2);
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Redirection: `>` `>>` `>|` `N>` `&>` `&>>`. The optional leading fd digits
|
|
712
|
+
// are ordinary characters we already copied; only the operator matters.
|
|
713
|
+
if (ch === '>' || (ch === '&' && command[i + 1] === '>')) {
|
|
714
|
+
let j = ch === '&' ? i + 2 : i + 1;
|
|
715
|
+
if (command[j] === '>') j++;
|
|
716
|
+
if (command[j] === '|') j++;
|
|
717
|
+
if (command[j] === '&') {
|
|
718
|
+
const dup = readWord(command, j + 1);
|
|
719
|
+
if (FD_DUP_RE.test(dup.value)) { out += ' '; i = Math.max(dup.end, j + 1); continue; }
|
|
720
|
+
j += 1;
|
|
721
|
+
}
|
|
722
|
+
while (command[j] === ' ' || command[j] === '\t') j++;
|
|
723
|
+
const w = readWord(command, j);
|
|
724
|
+
if (!w.balanced) return { targets, sanitized: out, balanced: false };
|
|
725
|
+
if (w.value) targets.push(w.value);
|
|
726
|
+
out += ' ';
|
|
727
|
+
i = Math.max(w.end, j, i + 1);
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
out += ch;
|
|
732
|
+
i++;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return { targets, sanitized: out, balanced: true };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Find a non-redirect write verb (`tee`, `dd of=`, `cp`/`mv` destination)
|
|
740
|
+
* whose target is the ledger.
|
|
741
|
+
*
|
|
742
|
+
* Runs on the TOKENIZED command, so quoting is handled by the lexer: a
|
|
743
|
+
* `tee '.orchestrator/metrics/sessions.jsonl'` target is still seen, while a
|
|
744
|
+
* `tee` appearing only inside a quoted payload is not in verb position.
|
|
745
|
+
*
|
|
746
|
+
* Takes the SANITIZED command from {@link scanCommand}, not the raw one. Since
|
|
747
|
+
* #965 the reason is no longer comments/here-docs/`$'…'` — the shared lexer
|
|
748
|
+
* handles those now — but REDIRECT ELISION: `tokenizeCommand` emits a redirect
|
|
749
|
+
* target as an ordinary token, so on the raw string
|
|
750
|
+
* `cp foo …/sessions.jsonl > /dev/null` resolves its destination to
|
|
751
|
+
* `/dev/null` and allows. Measured; see the module docblock.
|
|
752
|
+
*
|
|
753
|
+
* Verb resolution is `resolveSegmentVerb`'s (#991) — flag-aware, so a wrapper's
|
|
754
|
+
* OPTIONS are consumed with it and `sudo -u root tee -a <ledger>` reaches the
|
|
755
|
+
* real verb instead of stopping at `-u`.
|
|
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
|
+
*
|
|
766
|
+
* @param {string} command - sanitized command
|
|
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.
|
|
773
|
+
* @returns {string|null} the offending target, or null
|
|
774
|
+
*/
|
|
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
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// `env -S 'tee -a <ledger>'` hides a whole command line in one operand.
|
|
803
|
+
// Recurse on the payload with the SAME matcher rather than a second,
|
|
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');
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
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);
|
|
829
|
+
|
|
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
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
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;
|
|
877
|
+
}
|
|
878
|
+
|
|
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
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* The single matcher seam. Returns the offending write target, or null when the
|
|
892
|
+
* command carries no direct ledger write.
|
|
893
|
+
*
|
|
894
|
+
* Deliberately NOT exported: this module runs `main()` on import, so a test that
|
|
895
|
+
* imported the matcher would block on stdin. The fake-regression proof
|
|
896
|
+
* neutralises this function in place and re-runs the spawned-hook tests.
|
|
897
|
+
*
|
|
898
|
+
* @param {string} command
|
|
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.
|
|
903
|
+
* @returns {string|null}
|
|
904
|
+
*/
|
|
905
|
+
function findLedgerWrite(command, depth = 0, marks = []) {
|
|
906
|
+
if (typeof command !== 'string' || command.length === 0) return null;
|
|
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;
|
|
910
|
+
|
|
911
|
+
const scan = scanCommand(command);
|
|
912
|
+
for (const target of scan.targets) {
|
|
913
|
+
if (refersToLedger(target)) return target;
|
|
914
|
+
}
|
|
915
|
+
// Fail-CLOSED on an unbalanced quote (see docblock): the scan could not be
|
|
916
|
+
// trusted past the break, and the command mentions the ledger. Bash would
|
|
917
|
+
// reject it with a syntax error anyway, so the false-positive cost is ~0
|
|
918
|
+
// while "confuse the lexer" stops being a bypass.
|
|
919
|
+
if (!scan.balanced) {
|
|
920
|
+
return `${LEDGER_BASENAME} (unbalanced quote — command not parseable, denied fail-closed)`;
|
|
921
|
+
}
|
|
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();
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// ---------------------------------------------------------------------------
|
|
947
|
+
// Main
|
|
948
|
+
// ---------------------------------------------------------------------------
|
|
949
|
+
|
|
950
|
+
async function main() {
|
|
951
|
+
const input = await readStdin();
|
|
952
|
+
if (!input) return emitAllow();
|
|
953
|
+
|
|
954
|
+
// G1 — only Bash is gated.
|
|
955
|
+
if (input.tool_name !== 'Bash') return emitAllow();
|
|
956
|
+
|
|
957
|
+
// G2 — command must be a non-empty string.
|
|
958
|
+
const command = input?.tool_input?.command;
|
|
959
|
+
if (typeof command !== 'string' || command.length === 0) return emitAllow();
|
|
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
|
+
|
|
977
|
+
// G3 — matcher. No direct ledger write → allow.
|
|
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
|
+
}
|
|
994
|
+
|
|
995
|
+
// G4 — deny. emitDeny writes the envelope with fs.writeSync (#906/#914): a
|
|
996
|
+
// console.log here would be dropped above the 64 KiB pipe buffer, and a
|
|
997
|
+
// dropped envelope on this protocol reads as NO decision, i.e. fail-OPEN.
|
|
998
|
+
const shown = target.length > TARGET_ECHO_MAX
|
|
999
|
+
? `${target.slice(0, TARGET_ECHO_MAX)}… (${target.length} chars)`
|
|
1000
|
+
: target;
|
|
1001
|
+
|
|
1002
|
+
emitDeny(
|
|
1003
|
+
[
|
|
1004
|
+
`Direct write to the sessions ledger blocked: '${shown}'`,
|
|
1005
|
+
`The ledger is append-only through its validating writer:`,
|
|
1006
|
+
` node scripts/emit-session.mjs --entry '<json>' (or pipe the JSON on stdin)`,
|
|
1007
|
+
`Hand-composing a record and appending it with a shell redirect skips schema`,
|
|
1008
|
+
`validation — that is exactly how the malformed record in GitLab #958 landed.`,
|
|
1009
|
+
`Override (intentional maintenance only): run the session with`,
|
|
1010
|
+
`SO_DISABLED_HOOKS=pre-bash-sessions-ledger-guard`,
|
|
1011
|
+
`See: GitLab #958, skills/session-end/session-metrics-write.md`,
|
|
1012
|
+
].join('\n'),
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
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
|
+
|
|
1047
|
+
// Top-level error handler — fail-OPEN (see the module docblock). Never let a
|
|
1048
|
+
// non-zero exit leak: on this protocol exit 0 + empty stdout is "no decision".
|
|
1049
|
+
main().catch((e) => {
|
|
1050
|
+
process.stderr.write(
|
|
1051
|
+
`⚠ pre-bash-sessions-ledger-guard: internal error — ${e?.message || e}\n`,
|
|
1052
|
+
);
|
|
1053
|
+
process.exit(0);
|
|
1054
|
+
});
|