@holmes-lab/holmes-kit 0.1.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.
Files changed (107) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +102 -0
  4. package/bin/holmes-hook-antigravity.js +31 -0
  5. package/bin/holmes-kit.js +23 -0
  6. package/bin/holmes-mcp.js +34 -0
  7. package/bin/holmes-stop-antigravity.js +29 -0
  8. package/dist/.build-id +1 -0
  9. package/dist/holmes/cli/agents.js +168 -0
  10. package/dist/holmes/cli/doctor.js +625 -0
  11. package/dist/holmes/cli/gitignore-merge.js +84 -0
  12. package/dist/holmes/cli/governed-precondition.js +157 -0
  13. package/dist/holmes/cli/index.js +384 -0
  14. package/dist/holmes/cli/init.js +462 -0
  15. package/dist/holmes/cli/playbook-skills.js +711 -0
  16. package/dist/holmes/cli/roles-readme.js +134 -0
  17. package/dist/holmes/cli/settings-merge.js +122 -0
  18. package/dist/holmes/config/config.js +70 -0
  19. package/dist/holmes/context/bundler.js +114 -0
  20. package/dist/holmes/context/render.js +29 -0
  21. package/dist/holmes/context/tiers.js +110 -0
  22. package/dist/holmes/context/tokens.js +8 -0
  23. package/dist/holmes/cpg/cpg-scanner.js +213 -0
  24. package/dist/holmes/cpg/hash-cache.js +86 -0
  25. package/dist/holmes/cpg/language-parser-walk.js +917 -0
  26. package/dist/holmes/cpg/language-parser-worker.js +81 -0
  27. package/dist/holmes/cpg/language-parser.js +234 -0
  28. package/dist/holmes/cpg/scan-cache.js +108 -0
  29. package/dist/holmes/cpg/source-path.js +44 -0
  30. package/dist/holmes/cpg/test-files.js +84 -0
  31. package/dist/holmes/governance/constitution-debt.js +73 -0
  32. package/dist/holmes/governance/constitution-report.js +25 -0
  33. package/dist/holmes/governance/constitution.js +129 -0
  34. package/dist/holmes/governance/identity.js +30 -0
  35. package/dist/holmes/governance/ledger-lock.js +165 -0
  36. package/dist/holmes/governance/ledger-store.conformance.js +90 -0
  37. package/dist/holmes/governance/ledger-store.js +106 -0
  38. package/dist/holmes/governance/progress-ledger.js +83 -0
  39. package/dist/holmes/governance/provenance-chain.js +365 -0
  40. package/dist/holmes/governance/provenance-ledger.js +0 -0
  41. package/dist/holmes/governance/provenance-schema.js +47 -0
  42. package/dist/holmes/governance/replica-id.js +106 -0
  43. package/dist/holmes/governance/role-policy.js +137 -0
  44. package/dist/holmes/governance/trust-score.js +43 -0
  45. package/dist/holmes/guardrail/anchors.js +31 -0
  46. package/dist/holmes/guardrail/blind-spots.js +38 -0
  47. package/dist/holmes/guardrail/decision-ledger.js +107 -0
  48. package/dist/holmes/guardrail/executable-artifact.js +129 -0
  49. package/dist/holmes/guardrail/governance-history.js +101 -0
  50. package/dist/holmes/guardrail/phase.js +169 -0
  51. package/dist/holmes/guardrail/risk-classifier.js +450 -0
  52. package/dist/holmes/guardrail/risk-gate.js +160 -0
  53. package/dist/holmes/guardrail/risk-types.js +6 -0
  54. package/dist/holmes/guardrail/tspec-state.js +392 -0
  55. package/dist/holmes/guardrail/write-target.js +224 -0
  56. package/dist/holmes/hooks/adapters/antigravity.js +194 -0
  57. package/dist/holmes/hooks/pre-tool-use.js +1262 -0
  58. package/dist/holmes/hooks/stop.js +416 -0
  59. package/dist/holmes/mcp/basis.js +162 -0
  60. package/dist/holmes/mcp/handlers.js +1831 -0
  61. package/dist/holmes/mcp/server.js +71 -0
  62. package/dist/holmes/mcp/stdio-client.js +165 -0
  63. package/dist/holmes/mcp/supervisor.js +178 -0
  64. package/dist/holmes/mcp/tool-schemas.js +394 -0
  65. package/dist/holmes/mcp/validate-args.js +281 -0
  66. package/dist/holmes/messages/registry.js +50 -0
  67. package/dist/holmes/project/baseline.js +210 -0
  68. package/dist/holmes/project/change-source.js +233 -0
  69. package/dist/holmes/project/ignore.js +145 -0
  70. package/dist/holmes/project/root.js +113 -0
  71. package/dist/holmes/reverse/anchor.js +162 -0
  72. package/dist/holmes/reverse/cluster.js +187 -0
  73. package/dist/holmes/reverse/draft.js +151 -0
  74. package/dist/holmes/reverse/dynamic-wiring.js +47 -0
  75. package/dist/holmes/reverse/scan.js +194 -0
  76. package/dist/holmes/reverse/surface.js +154 -0
  77. package/dist/holmes/reverse/test-map.js +263 -0
  78. package/dist/holmes/review/coverage.js +33 -0
  79. package/dist/holmes/review/findings.js +123 -0
  80. package/dist/holmes/review/package.js +40 -0
  81. package/dist/holmes/review/review-targets.js +92 -0
  82. package/dist/holmes/review/scope.js +57 -0
  83. package/dist/holmes/review/test-evidence.js +77 -0
  84. package/dist/holmes/review/test-runner.js +572 -0
  85. package/dist/holmes/rtm/dataflow-taint.js +262 -0
  86. package/dist/holmes/rtm/gap-analyzer.js +27 -0
  87. package/dist/holmes/rtm/git-changes.js +72 -0
  88. package/dist/holmes/rtm/incremental.js +45 -0
  89. package/dist/holmes/rtm/localize.js +100 -0
  90. package/dist/holmes/rtm/rtm-builder.js +191 -0
  91. package/dist/holmes/rtm/rtm-check.js +89 -0
  92. package/dist/holmes/rtm/rtm-graph.js +232 -0
  93. package/dist/holmes/rtm/taint.js +92 -0
  94. package/dist/holmes/rtm/test-scope.js +336 -0
  95. package/dist/holmes/spec/approval-blockers.js +204 -0
  96. package/dist/holmes/spec/breaking-change.js +89 -0
  97. package/dist/holmes/spec/legacy-format.js +87 -0
  98. package/dist/holmes/spec/spec-digest.js +71 -0
  99. package/dist/holmes/spec/spec-parser.js +106 -0
  100. package/dist/holmes/spec/spec-store.conformance.js +118 -0
  101. package/dist/holmes/spec/spec-store.js +331 -0
  102. package/dist/holmes/spec/spec-types.js +177 -0
  103. package/dist/holmes/spec/validator.js +280 -0
  104. package/package.json +76 -0
  105. package/playbooks/adopt/PLAYBOOK.md +125 -0
  106. package/playbooks/author-slice/PLAYBOOK.md +119 -0
  107. package/playbooks/promote-slice/PLAYBOOK.md +134 -0
@@ -0,0 +1,450 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.rmCommandTargets = rmCommandTargets;
4
+ exports.scopeAxToProject = scopeAxToProject;
5
+ exports.normalizeCommandPaths = normalizeCommandPaths;
6
+ exports.classifyReversibility = classifyReversibility;
7
+ exports.classifyArchitectureReversal = classifyArchitectureReversal;
8
+ exports.classifySecret = classifySecret;
9
+ exports.classifyBlastRadius = classifyBlastRadius;
10
+ exports.assessRisk = assessRisk;
11
+ // @implements A-SPEC-125.1
12
+ const risk_types_1 = require("./risk-types");
13
+ const AWS_KEY_RE = /AKIA[0-9A-Z]{16}/;
14
+ const PEM_KEY_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
15
+ const SECRET_KV_RE = /(password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S{6,}/i;
16
+ // A long token-shaped run — NOT a true entropy measure (it matches SHAs/UUIDs/base64/
17
+ // long identifiers too). Used only as a conservative FALLBACK when the specific
18
+ // AWS/PEM/kv patterns miss; false-positive-heavy by design (false-positive > false-negative).
19
+ const LONG_TOKEN_RE = /[A-Za-z0-9+/_-]{20,}/;
20
+ // Token-based (not adjacency-based) matches so global flags between subcommands
21
+ // (`git --no-pager push --force`) and case variations (`GIT PUSH --FORCE`) are still caught.
22
+ const GIT_TOKEN_RE = /\bgit\b/i;
23
+ const PUSH_TOKEN_RE = /\bpush\b/i;
24
+ const RESET_TOKEN_RE = /\breset\b/i;
25
+ const HARD_FLAG_RE = /--hard\b/i;
26
+ // A bare `--force` (not merely the prefix of `--force-with-lease`). Scans the whole command,
27
+ // so `--force-with-lease --force` still matches on the second, standalone occurrence.
28
+ const FORCE_RE = /--force(?![-\w])|--force=/i;
29
+ // The SHORT force flag for `git push` (`-f`, incl. clusters `-fu`/`-uf`), scoped to AFTER the
30
+ // `push` subcommand within its segment (up to a shell separator). Scoping avoids mis-attributing an
31
+ // unrelated later `-f` (e.g. `git push origin main && rm -f tmp`) as a force push, and the
32
+ // single-dash start means `--force-with-lease` never matches it. (Corpus finding: `git push -f`
33
+ // slipped the `--force`-only rule.)
34
+ const PUSH_FORCE_SHORT_RE = /\bpush\b[^;&|\n]*\s-[a-z]*f[a-z]*(?:\s|$)/i;
35
+ // Recursive+force `rm`, scoped to each `rm` invocation up to a shell separator so an
36
+ // unrelated later subcommand's flag (e.g. `rm x && terraform apply --force`) is not
37
+ // misattributed, and so an unrelated long option on the rm line (`rm x --format json`)
38
+ // does not spuriously match. Catches combined clusters (`-rf`, `-fr`, `-rfv`) AND
39
+ // separated short flags (`-r -f`) AND long options (`--recursive --force`).
40
+ const RM_SEGMENT_RE = /\brm\b[^;&|\n]*/gi;
41
+ const RM_RECURSIVE_RE = /(?:^|\s)-[a-z]*r[a-z]*(?:\s|$)|(?:^|\s)--recursive(?:\s|$)/i;
42
+ const RM_FORCE_RE = /(?:^|\s)-[a-z]*f[a-z]*(?:\s|$)|(?:^|\s)--force(?:\s|$)/i;
43
+ // A recursive-force `rm` is whole-tree destruction ONLY when its target isn't a manifestly-safe,
44
+ // regenerable build/dependency/cache dir. Cleaning `build/`, `dist/`, `node_modules/`, `target/`,
45
+ // etc. is a routine non-destructive dev command — blanket-blocking it as hard-hitl broke real
46
+ // C++/JS/Rust rebuild workflows (empirical benchmark finding). The allowlist is deliberately narrow
47
+ // (well-known output dirs, relative, no root/home/glob/parent-traversal), so genuinely dangerous
48
+ // deletes (`rm -rf /`, `~`, `.`, `*`, `src/…`, `.ax/…`) stay hard-hitl.
49
+ const SAFE_RM_DIRS = new Set([
50
+ 'build', 'dist', 'node_modules', 'target', 'out', 'coverage', '.cache',
51
+ '.next', '.nuxt', '.gradle', '__pycache__', '.pytest_cache', '.turbo', '.parcel-cache',
52
+ 'cmake-build-debug', 'cmake-build-release',
53
+ // CMake regenerable reconfigure-clean artifacts — `rm -rf CMakeCache.txt CMakeFiles` is the
54
+ // standard "wipe the cache and re-run cmake" step (empirical benchmark finding: blocking it
55
+ // broke C++ rebuild workflows). All are cmake-generated + trivially regenerated by re-running cmake.
56
+ 'CMakeCache.txt', 'CMakeFiles', 'cmake_install.cmake', 'CTestTestfile.cmake',
57
+ ]);
58
+ function isSafeScopedRmTarget(raw) {
59
+ const unq = raw.replace(/^["']/, '').replace(/["']$/, ''); // strip one surrounding quote pair
60
+ const t = unq.replace(/\/+$/, '').replace(/^(?:\.\/)+/, ''); // strip trailing / and leading ./
61
+ if (!t || t === '.' || t === '..')
62
+ return false;
63
+ // Only a CLEAN simple path may be safe. Any shell metacharacter means we cannot statically know
64
+ // the real target, so we must treat it as dangerous — e.g. brace expansion `build/{y,../src}`
65
+ // expands to delete `../src` (a bypass that a bare `..`-segment check misses), and `$()`,
66
+ // backticks, globs, quotes, `~` similarly hide the true target. Whitelist charset only.
67
+ if (!/^[A-Za-z0-9._/-]+$/.test(t))
68
+ return false;
69
+ if (t.split('/').some((s) => s === '..'))
70
+ return false; // any parent traversal
71
+ const segs = t.split('/').filter(Boolean);
72
+ if (t.startsWith('/')) {
73
+ // ABSOLUTE: allow ONLY when the FINAL segment is itself a known regenerable build dir AND there
74
+ // is at least one parent segment — i.e. a scoped absolute build clean like
75
+ // `/path/to/work/build` (agents clean build dirs by absolute path too). A bare top-level
76
+ // `/build` (segs.length 1) or a system path (`/usr/lib`, `/etc`) is never waved through.
77
+ // ABSOLUTE: safe-scoped ONLY under a known ephemeral/scratch root (/tmp, /var/folders, /private/*).
78
+ // The old "final segment is a build-dir name" rule (review G-F2) waved through ANY absolute path
79
+ // with a build-ish basename regardless of location — e.g. rm -rf /home/victim/target, /root/.cache.
80
+ // A legitimate in-project absolute build clean is instead vouched for by the hook's git inProject
81
+ // signal; without that signal a non-ephemeral absolute path cannot be statically proven safe.
82
+ return EPHEMERAL_ABS_ROOT_RE.test(t);
83
+ }
84
+ return SAFE_RM_DIRS.has(segs[0]); // relative: first segment is a known build dir
85
+ }
86
+ function rmTargets(segment) {
87
+ return segment.replace(/^\s*rm\b/i, '').split(/\s+/).filter((tok) => tok && !tok.startsWith('-'));
88
+ }
89
+ // The rm target tokens the classifier will look up in `rmTargetSignals`, across every RECURSIVE rm
90
+ // segment of a command. Exported so the hook resolves git facts for EXACTLY these tokens (the signal
91
+ // map keys must match what rmDangerReason reads). Pure (no I/O) — the hook does the git resolution.
92
+ function rmCommandTargets(cmd) {
93
+ const segments = cmd.match(RM_SEGMENT_RE);
94
+ if (!segments)
95
+ return [];
96
+ const out = [];
97
+ for (const seg of segments) {
98
+ if (RM_RECURSIVE_RE.test(seg))
99
+ out.push(...rmTargets(seg));
100
+ }
101
+ return out;
102
+ }
103
+ // Clean-charset literal targets that make a RECURSIVE `rm` whole-tree destruction even WITHOUT
104
+ // `-f` and would otherwise slip past the metachar/absolute checks below (they contain no shell
105
+ // metacharacter and are not absolute). Root `/`, `$HOME`, globs, braces, `$()`, and non-ephemeral
106
+ // absolute/system paths are handled structurally in isDangerousUnforcedRmTarget, not here.
107
+ const CATASTROPHIC_RM_TARGET_RES = [
108
+ /^\.\/?$/, // the current working directory (`.` or `./`)
109
+ ];
110
+ // Absolute roots under which a recursive delete of a NESTED path is routine (OS/build temp).
111
+ // A recursive rm of an absolute path OUTSIDE these is treated as catastrophic on the no-force
112
+ // path — so system paths (`/usr/lib`, `/System/Library`, `/etc/ssh`, `/etc/*`) can never be
113
+ // waved through just because `-f` is absent (adversarial-review findings #1/#2).
114
+ const EPHEMERAL_ABS_ROOT_RE = /^\/(private\/)?(tmp|var\/tmp|var\/folders)(\/|$)/;
115
+ // The NON-git-overridable floor: targets that are NEVER safe to recursively delete, no matter what
116
+ // git says — filesystem root, any home-relative path (tilde), the cwd, parent-traversal, and any
117
+ // opaque shell-expansion (metachar) whose real target can't be known statically. `git check-ignore`
118
+ // can't make `/` or `~/.ssh` safe, so these short-circuit before any signal is consulted.
119
+ function isCatastrophicRmFloor(raw) {
120
+ const unquoted = raw.replace(/^["']/, '').replace(/["']$/, '');
121
+ const t = unquoted === '/' ? '/' : unquoted.replace(/\/+$/, '');
122
+ if (!t)
123
+ return false;
124
+ if (t === '/')
125
+ return true; // filesystem root
126
+ if (t === '..' || t.split('/').some((s) => s === '..'))
127
+ return true; // any parent-traversal segment
128
+ if (t.startsWith('~'))
129
+ return true; // any home-relative path (~, ~/x, ~user)
130
+ if (/[^A-Za-z0-9._/~-]/.test(t))
131
+ return true; // opaque/metachar (can expand to /)
132
+ if (CATASTROPHIC_RM_TARGET_RES.some((re) => re.test(t)))
133
+ return true; // cwd literal
134
+ return false;
135
+ }
136
+ // Per-target verdict for a RECURSIVE delete, git-aware:
137
+ // 'safe' — a regenerable build artifact or manifestly-scoped path; no gate, even with -f
138
+ // 'dangerous' — catastrophic, version-controlled source, or out-of-project; must hard-hitl
139
+ // 'unknown' — cannot prove either way here; the caller applies the -f / no-f latitude rule
140
+ // Layer order: catastrophic FLOOR (git-independent) → GIT signal (authoritative when the hook
141
+ // resolved it) → static NAME allowlist fallback (relative build dirs, absolute build paths, CMake).
142
+ function rmTargetSafety(raw, sig) {
143
+ if (isCatastrophicRmFloor(raw))
144
+ return 'dangerous';
145
+ if (sig) {
146
+ if (sig.tracked)
147
+ return 'dangerous'; // git-tracked source under VCS
148
+ if (sig.inProject === false)
149
+ return 'dangerous'; // resolves outside the project tree
150
+ if (sig.gitIgnored && sig.inProject)
151
+ return 'safe'; // regenerable build artifact (ANY name)
152
+ // in-project, untracked, not-ignored → inconclusive; fall through to the static checks
153
+ }
154
+ if (isSafeScopedRmTarget(raw))
155
+ return 'safe'; // name allowlist: build/, /path/build, CMake…
156
+ const t = raw.replace(/^["']/, '').replace(/["']$/, '').replace(/\/+$/, '');
157
+ if (t.startsWith('/') && !EPHEMERAL_ABS_ROOT_RE.test(t))
158
+ return 'dangerous'; // system-area absolute
159
+ return 'unknown';
160
+ }
161
+ // Returns the hard-hitl reason for a destructive `rm`, or null. Per recursive `rm` segment:
162
+ // • any target 'dangerous' → hard-hitl (catastrophic / VCS-tracked / out-of-project / system)
163
+ // • all targets 'safe' → allowed even with -f (proven regenerable/scoped)
164
+ // • some 'unknown' + `-f` → hard-hitl (unattended batch delete of an unproven target)
165
+ // • some 'unknown', no `-f` → allowed (ordinary `rm -r dir` latitude)
166
+ // `signals` (per-target git/fs facts resolved by the hook) sharpen the verdict; absent, the static
167
+ // name-based logic applies unchanged (full backward compatibility — no signals == prior behavior).
168
+ // A NON-recursive `rm` still mass-deletes when its target is a whole-scope glob or the root/home
169
+ // itself — `rm *` / `rm -f *` wipes the entire cwd, `rm /*` / `rm ~/*` / `rm ~/.ssh/*` wipe root/home
170
+ // (review G-F1: these are unclassified because the floor was only consulted for recursive rm). A
171
+ // FILTERED relative glob (`*.log`, `build/*.o`) is left alone — only whole-scope / absolute / home
172
+ // globs and bare root/home count.
173
+ function isMassRmTarget(raw) {
174
+ const t = raw.replace(/^["']/, '').replace(/["']$/, '');
175
+ if (/^\*$/.test(t))
176
+ return true; // bare glob = every entry in cwd
177
+ if (/^[/~]/.test(t) && t.includes('*'))
178
+ return true; // absolute or home glob (/*, ~/*, ~/.ssh/*, /etc/*)
179
+ if (/^(\/|~\/?|\$\{?HOME\}?\/?)$/.test(t))
180
+ return true; // the whole filesystem root or home dir
181
+ return false;
182
+ }
183
+ function rmDangerReason(cmd, signals) {
184
+ const segments = cmd.match(RM_SEGMENT_RE);
185
+ if (!segments)
186
+ return null;
187
+ for (const seg of segments) {
188
+ // Mass-delete check applies to EVERY rm (recursive or not) — a non-recursive glob/root wipe is
189
+ // just as destructive. Runs before the recursion gate below.
190
+ if (rmTargets(seg).some(isMassRmTarget)) {
191
+ return 'rm of a whole-scope glob or root/home target — mass file destruction';
192
+ }
193
+ if (!RM_RECURSIVE_RE.test(seg))
194
+ continue; // remaining checks are recursive whole-tree risk
195
+ const targets = rmTargets(seg);
196
+ const safeties = targets.map((t) => rmTargetSafety(t, signals?.[t]));
197
+ if (safeties.some((s) => s === 'dangerous')) {
198
+ return 'recursive rm of a catastrophic / version-controlled / out-of-project / system target — whole-tree destruction';
199
+ }
200
+ if (targets.length > 0 && safeties.every((s) => s === 'safe'))
201
+ continue; // all proven regenerable
202
+ if (RM_FORCE_RE.test(seg)) {
203
+ return 'rm -rf of an unverified target (not a proven regenerable/scoped path) — whole-tree destruction';
204
+ }
205
+ }
206
+ return null;
207
+ }
208
+ const DROP_TABLE_OR_DB_RE = /\bDROP\s+(TABLE|DATABASE)\b/i;
209
+ // Additional unambiguously destructive / irreversible / RCE shell patterns. This set is
210
+ // deliberately conservative (each is clearly high-risk) and NON-EXHAUSTIVE — command-space
211
+ // coverage is a calibration concern deferred per REQ-125; false-negatives on unlisted
212
+ // destructive commands are possible and must not be over-trusted (other layers + human
213
+ // oversight remain). Each is scanned across the whole command string, case-insensitively.
214
+ const DESTRUCTIVE_CMD_CHECKS = [
215
+ { re: /\bdd\b[^;&|\n]*\bof=\/dev\//i, reason: 'dd writing to a raw device (irreversible disk overwrite)' },
216
+ { re: /\bmkfs(\.\w+)?\b/i, reason: 'mkfs formats a filesystem (irreversible)' },
217
+ { re: /\bshred\b/i, reason: 'shred irreversibly destroys file contents' },
218
+ // Raw-device redirect — widened past sd/disk/nvme/hd to rdisk (macOS), device-mapper, loop, md, vd (review F5).
219
+ { re: />\s*\/dev\/(r?disk|sd|nvme|hd|vd|md|loop|mapper)/i, reason: 'redirect overwrites a raw device' },
220
+ // Pipe-to-shell RCE — `[^;&\n]*` now CROSSES intermediate `|` stages (curl|tee|sh, curl|grep|sh) and
221
+ // covers more interpreters (python/perl/ruby/node/php/ksh), which the old single-pipe list missed (F5).
222
+ { re: /\b(curl|wget|fetch)\b[^;&\n]*\|[^;&\n]*\b(sudo\s+)?(sh|bash|zsh|dash|ksh|python[0-9.]*|perl|ruby|node|php)\b/i, reason: 'pipe-to-shell executes remote code (RCE)' },
223
+ { re: /\bgit\s+clean\b[^;&|\n]*-[a-z]*f/i, reason: 'git clean -f irreversibly removes untracked files' },
224
+ { re: /\bfind\b[^;&|\n]*\s-delete\b/i, reason: 'find -delete irreversibly removes matched files' },
225
+ // Discard ALL uncommitted work — same harm as `git reset --hard`, previously unchecked (review F3).
226
+ { re: /\bgit\s+(restore|checkout)\b[^;&|\n]*(?:\s--)?\s\.(?:\s|$)/i, reason: 'git restore/checkout . discards all uncommitted work irreversibly' },
227
+ { re: /\bgit\s+branch\b[^;&|\n]*\s-D\b/i, reason: 'git branch -D force-deletes a branch' },
228
+ // Recursive chmod on a catastrophic target bricks permissions; truncate -s 0 zeroes a file (F3).
229
+ { re: /\bchmod\b\s+-[a-z]*R[a-z]*\b[^;&|\n]*\s(\/|~|\.)(\s|$)/i, reason: 'recursive chmod on root/home/cwd bricks permissions' },
230
+ { re: /\btruncate\b[^;&|\n]*-s\s*0\b/i, reason: 'truncate -s 0 irreversibly zeroes a file' },
231
+ // @implements A-SPEC-196 (Windows Native CMD / PowerShell destructive command classification)
232
+ { re: /\b(?:Remove-Item|ri)\b[^;&|\n]*-(?:Recurse|Force|r|f)\b/i, reason: 'PowerShell Remove-Item -Recurse/-Force deletes file trees (irreversible)' },
233
+ { re: /\bdel\b[^;&|\n]*\/(s|f|q)\b/i, reason: 'Windows CMD del /s deletes file trees (irreversible)' },
234
+ { re: /\b(rmdir|rd)\b[^;&|\n]*\/s\b/i, reason: 'Windows CMD rmdir /s deletes directory trees (irreversible)' },
235
+ ];
236
+ // Protected .ax governance paths, matched as a path SEGMENT so absolute (`/proj/.ax/specs/x`)
237
+ // and `./`-relative (`./.ax/decisions/x`) forms are caught, not just a literal string prefix.
238
+ /**
239
+ * Neutralise `.ax` paths that belong to ANOTHER project before the protected-path rules run.
240
+ *
241
+ * `.ax/specs` is this project's governance directory, and deleting it must stay non-bypassable. But
242
+ * the rules match the substring anywhere in a command, so `rm /other/project/.ax/cpg_cache/x.json`
243
+ * was gated too — found by dogfooding while cleaning up a scan cache left in a DIFFERENT repository,
244
+ * which is exactly what a user juggling several projects does every day. The governance claim only
245
+ * extends to this project's own `.ax`.
246
+ *
247
+ * Only ABSOLUTE paths outside the project root are masked. A relative `.ax/...` resolves against the
248
+ * project and stays protected, so nothing is weakened for the case the rule exists to cover. With no
249
+ * projectRoot supplied the command is returned unchanged — unknown scope stays conservative.
250
+ */
251
+ function scopeAxToProject(cmd, projectRoot) {
252
+ if (!projectRoot)
253
+ return cmd;
254
+ const root = projectRoot.replace(/\\/g, '/').replace(/\/+$/, '');
255
+ return cmd.replace(/(^|[\s'"=(:])(\/[^\s'"()]*\/\.ax\/[^\s'"()]*)/g, (m, pre, abs) => (abs === root || abs.startsWith(`${root}/`) ? m : `${pre}<other-project-path>`));
256
+ }
257
+ // `state` joined the protected set with P3 (REQ-134): it holds the constitution-debt and the
258
+ // last-green baseline, so a single-file `rm` there would bypass the WRITE_CODE debt gate — measured
259
+ // in the campaign completeness critique. Deleting governance state is now hard-hitl like every other.
260
+ const PROTECTED_AX_PATH_RE = /(^|\/)\.ax\/(specs|decisions|ledger|cpg_cache|state|roles)(\/|$)/;
261
+ // A removal verb + a protected .ax path anywhere in a shell command — the command form of
262
+ // the governance-path delete, which the structured `kind:'delete'` branch alone would miss.
263
+ // Not just removal: RELOCATING or CLOBBERING a protected .ax path is equally destructive to it, so
264
+ // mv/cp/git-mv and a `>` truncation count too (review F4 — `mv .ax/specs /tmp` evaded the rm-only rule).
265
+ const REMOVAL_VERB_RE = /\b(rm|rmdir|unlink|git\s+rm|git\s+mv|mv|cp)\b/i;
266
+ const CLOBBER_AX_RE = />\s*['"]?[^\s;&|]*\.ax\/(specs|decisions|ledger|cpg_cache|state|roles)\b/i;
267
+ const CMD_PROTECTED_AX_RE = /(^|[\s'"=(/])\.ax\/(specs|decisions|ledger|cpg_cache|state|roles)(\/|\b)/;
268
+ /**
269
+ * Normalize a command string for protected-path matching: collapse `//` runs and `./` segments so
270
+ * `./.ax//ledger/x` matches the same rules as `.ax/ledger/x` (adversarial probe: those forms slipped
271
+ * every protected-path regex). Also flattens a leading `cd <dir> &&` so a cd-relative write
272
+ * (`cd .ax/ledger && cat > provenance.jsonl`) is seen against its real directory.
273
+ */
274
+ function normalizeCommandPaths(cmd) {
275
+ let out = cmd.replace(/(?<![:\w])\.\//g, '').replace(/([^:])\/{2,}/g, '$1/');
276
+ const cdChain = /(^|[;&|]\s*)cd\s+(['"]?)([^\s;&|'"]+)\2\s*(?:&&|;)\s*/;
277
+ for (let m = cdChain.exec(out); m; m = cdChain.exec(out)) {
278
+ const dir = m[3].replace(/\/+$/, '');
279
+ const rest = out.slice(m.index + m[0].length);
280
+ // Prefix bare relative paths in the remainder with the cd target so path rules see the real path.
281
+ const rewritten = rest.replace(/(^|[\s>|])(?![-/~'"])([\w@.][\w@./-]*)/g, (full, pre, p) => /^[A-Za-z0-9_.@-]+$/.test(p) || p.includes('/') ? `${pre}${dir}/${p}` : full);
282
+ out = out.slice(0, m.index + (m[1] ? m[1].length : 0)) + rewritten;
283
+ }
284
+ return out;
285
+ }
286
+ // File-mutating shell verbs beyond redirect/removal: these WRITE a target path, so a protected path
287
+ // as their argument is the same governance breach as `>` or `rm` (probe: tee/cp/install slipped).
288
+ const WRITE_VERB_AX_RE = /\b(?:tee|cp|install|dd|ln|mv|rsync)\b[^;&|\n]*\.ax\/(?:specs|decisions|ledger|cpg_cache|state|roles)\b/i;
289
+ // An interpreter one-liner naming a protected path: `node -e "...appendFileSync('.ax/ledger/...')"`,
290
+ // `python3 -c "open('.ax/ledger/...','a')"`. Read-only one-liners are not distinguishable from writes
291
+ // here, so this is deliberately conservative on the .ax governance surface only.
292
+ const INTERPRETER_AX_RE = /\b(?:node|python3?|ruby|perl|deno|bun)\b[^;&|\n]*-(?:e|c|-eval)\b[^\n]*\.ax\/(?:specs|decisions|ledger|cpg_cache|state|roles)\b/i;
293
+ /**
294
+ * @implements A-SPEC-125.1
295
+ * Reversibility axis: can the action's effect be undone? Conservative default —
296
+ * unknown kinds and ambiguous (tracked===undefined) states never resolve to 'auto'.
297
+ */
298
+ function classifyReversibility(action) {
299
+ const axis = 'reversibility';
300
+ const cmd = action.command ?? '';
301
+ // Command-based hard-hitl checks run regardless of `kind` — a dangerous shell command
302
+ // can be carried by any action (e.g. an `edit` action with an embedded `command`), so
303
+ // gating this scan on `kind==='shell'` would let it slip through under a different kind.
304
+ if (GIT_TOKEN_RE.test(cmd) && PUSH_TOKEN_RE.test(cmd) && (FORCE_RE.test(cmd) || PUSH_FORCE_SHORT_RE.test(cmd))) {
305
+ return { axis, level: 'hard-hitl', reasons: ['git push --force/-f rewrites remote history irreversibly'] };
306
+ }
307
+ if (GIT_TOKEN_RE.test(cmd) && RESET_TOKEN_RE.test(cmd) && HARD_FLAG_RE.test(cmd)) {
308
+ return { axis, level: 'hard-hitl', reasons: ['git reset --hard discards uncommitted work irreversibly'] };
309
+ }
310
+ // Removal targeting a protected .ax governance path is UNCONDITIONALLY hard-hitl —
311
+ // `.ax/specs`/`.ax/decisions` deletion is non-bypassable per H-SPEC-125, independent
312
+ // of `tracked` (the shell form of the delete-kind protected-path rule).
313
+ // Protected-path checks run on the NORMALIZED command so `//`, `./`, and `cd <dir> &&` forms cannot
314
+ // dodge the rules, and cover every mutation channel: removal verbs, redirect clobber, file-writing
315
+ // verbs (tee/cp/install/…), and interpreter one-liners naming a governance path.
316
+ const ncmd = scopeAxToProject(normalizeCommandPaths(cmd), action.projectRoot);
317
+ if ((REMOVAL_VERB_RE.test(ncmd) && CMD_PROTECTED_AX_RE.test(ncmd)) || CLOBBER_AX_RE.test(ncmd)
318
+ || WRITE_VERB_AX_RE.test(ncmd) || INTERPRETER_AX_RE.test(ncmd)) {
319
+ return { axis, level: 'hard-hitl', reasons: ['removal command targets a protected .ax governance path'] };
320
+ }
321
+ // A destructive recursive `rm` — either `-rf` on a non-safe target, or a recursive delete of
322
+ // a catastrophic root (/, ~, $HOME, ., .., glob) even WITHOUT `-f` — is whole-tree destruction,
323
+ // unconditionally hard-hitl regardless of `tracked`. The single-file `tracked===true` auto
324
+ // exception lives only in the structured delete-kind branch below; a recursive delete never qualifies.
325
+ const rmReason = rmDangerReason(cmd, action.rmTargetSignals);
326
+ if (rmReason) {
327
+ return { axis, level: 'hard-hitl', reasons: [rmReason] };
328
+ }
329
+ if (DROP_TABLE_OR_DB_RE.test(cmd)) {
330
+ return { axis, level: 'hard-hitl', reasons: ['destructive DDL (DROP TABLE/DATABASE)'] };
331
+ }
332
+ for (const check of DESTRUCTIVE_CMD_CHECKS) {
333
+ if (check.re.test(cmd))
334
+ return { axis, level: 'hard-hitl', reasons: [check.reason] };
335
+ }
336
+ if (action.kind === 'delete') {
337
+ const normalizedTarget = action.target.replace(/\\/g, '/');
338
+ // A trailing slash is an additional POSITIVE directory signal; its absence must NOT
339
+ // imply "not a directory" — that inference is exactly what let directory deletes slip
340
+ // to 'auto'. Only an explicit isDirectory===false (alongside tracked===true) is 'auto'.
341
+ const looksLikeDirectory = normalizedTarget.endsWith('/') || action.isDirectory === true;
342
+ if (PROTECTED_AX_PATH_RE.test(normalizedTarget)) {
343
+ return { axis, level: 'hard-hitl', reasons: ['delete under protected .ax governance path'] };
344
+ }
345
+ if (looksLikeDirectory) {
346
+ return { axis, level: 'hard-hitl', reasons: ['delete targets a directory'] };
347
+ }
348
+ if (action.tracked === false) {
349
+ return { axis, level: 'hard-hitl', reasons: ['delete of an untracked path'] };
350
+ }
351
+ if (action.tracked === true && action.isDirectory === false) {
352
+ return { axis, level: 'auto', reasons: [] };
353
+ }
354
+ // Ambiguous: tracked and/or directory status not conclusively known. Never auto.
355
+ return { axis, level: 'confirm', reasons: ['ambiguous: tracked/directory status not conclusively known for delete target'] };
356
+ }
357
+ if (action.kind === 'edit') {
358
+ return { axis, level: 'auto', reasons: [] };
359
+ }
360
+ // Unknown/unhandled kind: conservative default, never auto.
361
+ return { axis, level: 'confirm', reasons: [`ambiguous: unhandled action kind '${action.kind}'`] };
362
+ }
363
+ /**
364
+ * @implements A-SPEC-125.1
365
+ * Architecture axis: does this action reverse a previously accepted architectural decision?
366
+ */
367
+ function classifyArchitectureReversal(action) {
368
+ const axis = 'architecture';
369
+ const supersedes = action.adrSupersedes ?? [];
370
+ const supersedesAdr = supersedes.length > 0;
371
+ const changesConstraints = action.cspecConstraintChange === true;
372
+ if (supersedesAdr || changesConstraints) {
373
+ const reasons = [];
374
+ if (supersedesAdr)
375
+ reasons.push(`supersedes accepted ADR(s): ${supersedes.join(', ')}`);
376
+ if (changesConstraints)
377
+ reasons.push('C-SPEC Forbidden Edges / Layer Rules changed');
378
+ return { axis, level: 'hard-hitl', reasons };
379
+ }
380
+ return { axis, level: 'auto', reasons: [] };
381
+ }
382
+ /**
383
+ * @implements A-SPEC-125.1
384
+ * Secret axis: does the staged content contain a credential-shaped string?
385
+ */
386
+ function classifySecret(action) {
387
+ const axis = 'secret';
388
+ const content = action.stagedContent ?? '';
389
+ const reasons = [];
390
+ if (AWS_KEY_RE.test(content))
391
+ reasons.push('AWS access key pattern detected');
392
+ if (PEM_KEY_RE.test(content))
393
+ reasons.push('PEM private key block detected');
394
+ if (SECRET_KV_RE.test(content))
395
+ reasons.push('password/token/api_key assignment detected');
396
+ if (reasons.length === 0 && LONG_TOKEN_RE.test(content))
397
+ reasons.push('long token-shaped string detected (conservative fallback)');
398
+ if (reasons.length > 0)
399
+ return { axis, level: 'hard-hitl', reasons };
400
+ return { axis, level: 'auto', reasons: [] };
401
+ }
402
+ const DEFAULT_BLAST_CONFIRM = 8;
403
+ const DEFAULT_BLAST_HARD_HITL = 20;
404
+ /**
405
+ * @implements A-SPEC-125.3
406
+ * Blast-radius axis: how far does this change's impact ripple through the spec graph?
407
+ * The graph impact signal (`action.blastRadius`) is INJECTED by the caller — this function
408
+ * remains pure, performing no graph traversal or I/O of its own.
409
+ */
410
+ function classifyBlastRadius(action, cfg) {
411
+ const axis = 'blast-radius';
412
+ const br = action.blastRadius;
413
+ if (!br)
414
+ return { axis, level: 'auto', reasons: [] };
415
+ // A blastRadius signal was supplied but its count is unreadable — NaN, or a non-number
416
+ // from a malformed injected/deserialized signal. Fail SAFE, not to 'auto': a
417
+ // present-but-unreadable impact signal escalates to confirm, matching this module's
418
+ // conservative-default philosophy (unknown never silently resolves to the least-protective
419
+ // outcome). NaN>=T is always false, which would otherwise fall through to 'auto'.
420
+ // Infinity is intentionally NOT caught here — it is a readable "unbounded blast" and
421
+ // correctly resolves to hard-hitl via the `>= hardT` comparison below.
422
+ if (typeof br.impactedSpecCount !== 'number' || Number.isNaN(br.impactedSpecCount)) {
423
+ return { axis, level: 'confirm', reasons: ['blast-radius signal present but impactedSpecCount is not a readable number'] };
424
+ }
425
+ const hardT = cfg?.blastRadiusHardHitl ?? DEFAULT_BLAST_HARD_HITL;
426
+ const confirmT = cfg?.blastRadiusConfirm ?? DEFAULT_BLAST_CONFIRM;
427
+ if (br.impactedSpecCount >= hardT)
428
+ return { axis, level: 'hard-hitl', reasons: [`impacts ${br.impactedSpecCount} specs (>= ${hardT})`] };
429
+ if (br.hitsFoundational)
430
+ return { axis, level: 'confirm', reasons: ['change ripples to a foundational REQ node'] };
431
+ if (br.impactedSpecCount >= confirmT)
432
+ return { axis, level: 'confirm', reasons: [`impacts ${br.impactedSpecCount} specs (>= ${confirmT})`] };
433
+ return { axis, level: 'auto', reasons: [] };
434
+ }
435
+ /**
436
+ * @implements A-SPEC-125.1
437
+ * Runs all four axes and folds them into a single overall risk assessment.
438
+ * Pure function: no I/O, no clocks, no randomness — same input always yields the same output.
439
+ */
440
+ function assessRisk(action, cfg) {
441
+ const verdicts = [
442
+ classifyReversibility(action),
443
+ classifyArchitectureReversal(action),
444
+ classifySecret(action),
445
+ classifyBlastRadius(action, cfg),
446
+ ];
447
+ const level = verdicts.reduce((acc, v) => (0, risk_types_1.maxLevel)(acc, v.level), 'auto');
448
+ const reasons = verdicts.filter((v) => v.level !== 'auto').flatMap((v) => v.reasons);
449
+ return { level, verdicts, reasons };
450
+ }
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isValidApproval = isValidApproval;
4
+ exports.approvalCovers = approvalCovers;
5
+ exports.isNonceConsumed = isNonceConsumed;
6
+ exports.gradedResponse = gradedResponse;
7
+ exports.riskGate = riskGate;
8
+ /** All of actor/token/rationale must be present and non-empty (whitespace-only counts as empty). */
9
+ function isValidApproval(a) {
10
+ if (a == null)
11
+ return false;
12
+ // A malformed/deserialized Approval (missing keys, null/non-string fields) must
13
+ // return false, NEVER throw — the gate is handed unvalidated external JSON (MCP
14
+ // call / CLI flag / config), and a crash here would be a DoS on the safety
15
+ // mechanism (or a fail-open if a caller wraps it in try/catch). Guard the type.
16
+ return [a.actor, a.token, a.rationale].every((s) => typeof s === 'string' && s.trim().length > 0);
17
+ }
18
+ /**
19
+ * @implements A-SPEC-133
20
+ * Does this well-formed approval AUTHORIZE this specific action, right now? This is the seam P2
21
+ * adds beside isValidApproval: the gates ask "covers?" instead of "well-formed?", so one token
22
+ * stops being a master key. Pure — `now` is passed in (the action timestamp), never read from the
23
+ * clock, so the predicate stays deterministic and testable.
24
+ *
25
+ * - malformed → false (isValidApproval's meaning is preserved).
26
+ * - expired (epoch comparison, both operands must parse; unreadable expires OR now → false). Absent expires → no expiry.
27
+ * - scope present → true iff some entry FULL-matches (kind exact or `*`; the pattern is anchored
28
+ * at both ends, `*` a wildcard run, trailing `/` a directory prefix). No entry matches → false: an existing-but-empty scope denies
29
+ * everything, which is the safe reading of "scoped but matches nothing".
30
+ * - scope absent → true: the documented UNSCOPED master-key path (the operator's session token).
31
+ */
32
+ function approvalCovers(a, action, now) {
33
+ if (!isValidApproval(a))
34
+ return false;
35
+ const appr = a;
36
+ if (appr.expires !== undefined) {
37
+ // A PRESENT deadline judges — including ''/' ' (round-5: the trim-gate routed blank strings
38
+ // around the whole branch, and A-SPEC-133 says an unreadable deadline is a passed one).
39
+ // Epoch comparison, not lexicographic: `now` is always a Z-form ISO string, so a legitimate
40
+ // local-offset expires (+09:00) that was INSTANT-expired still sorted "after" it and lifted the
41
+ // gate (round-3 HIGH, fail-open). An unparseable expires is fail-closed — a deadline we cannot
42
+ // read is a deadline we must assume passed.
43
+ const exp = Date.parse(String(appr.expires));
44
+ const nowMs = Date.parse(now);
45
+ // BOTH operands fail closed (round-4: guarding only `expires` left `now` — which risk_check
46
+ // takes from the CALLER's ts — able to resurrect an expired approval via NaN comparison).
47
+ if (!Number.isFinite(exp) || !Number.isFinite(nowMs) || nowMs > exp)
48
+ return false;
49
+ }
50
+ if (appr.scope === undefined)
51
+ return true; // unscoped master key
52
+ if (!Array.isArray(appr.scope))
53
+ return false;
54
+ return appr.scope.some((s) => {
55
+ if (!s || typeof s.kind !== 'string' || typeof s.pattern !== 'string')
56
+ return false;
57
+ if (s.kind !== '*' && s.kind !== action.kind)
58
+ return false;
59
+ return patternMatches(s.pattern, action.target);
60
+ });
61
+ }
62
+ /**
63
+ * FULL match with `*` as a wildcard run — anchored at BOTH ends. `git push*` matches
64
+ * `git push --force`; `rm *` matches `rm -rf x` but NOT `confirm x`. Every documented pattern ends
65
+ * its open tail with an explicit `*`; the old start-only anchor made a bare pattern a PREFIX, so a
66
+ * scope narrowed to finding id `C1` also lifted `C10` and `C1-regression` (round-3 HIGH) and no
67
+ * exact-id scope was expressible ('$' is escaped as a literal). A pattern without `*` now means
68
+ * exactly what it says.
69
+ */
70
+ function patternMatches(pattern, target) {
71
+ // '' is NOT a wildcard (round-6): an empty pattern covered every target — the same degenerate
72
+ // fail-open the blank-expires fix closed. Full-match semantics give '' only the empty target.
73
+ if (pattern === '*')
74
+ return true;
75
+ // A trailing '/' is an unambiguous directory-prefix intent — `src/` covers `src/feature.ts`
76
+ // (the style A-SPEC-149's gate-isolation TEST sanctions for code-write scopes — the doc itself carries no pattern prose). Everything else matches in full.
77
+ const p = pattern.endsWith('/') ? `${pattern}*` : pattern;
78
+ const re = new RegExp('^' + p.split('*').map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$');
79
+ return re.test(target);
80
+ }
81
+ /**
82
+ * @implements A-SPEC-133
83
+ * Has this single-use nonce already been spent? Sync-reads the append-only provenance chain for a
84
+ * `nonce-consumed` entry carrying the nonce. A missing/unreadable ledger means nothing was consumed
85
+ * yet — false, never a throw (the gate must not crash on a fresh project).
86
+ */
87
+ function isNonceConsumed(nonce, ledgerFile) {
88
+ if (!nonce)
89
+ return false;
90
+ let text;
91
+ try {
92
+ // Local require to keep this module free of a load-time fs dependency for its pure consumers.
93
+ const fs = require('node:fs');
94
+ if (!fs.existsSync(ledgerFile))
95
+ return false;
96
+ text = fs.readFileSync(ledgerFile, 'utf8');
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ for (const line of text.split('\n')) {
102
+ if (!line.trim())
103
+ continue;
104
+ try {
105
+ const evt = JSON.parse(line);
106
+ if (evt.kind === 'nonce-consumed' && Array.isArray(evt.inputs) && evt.inputs.includes(nonce))
107
+ return true;
108
+ }
109
+ catch { /* skip unparseable line */ }
110
+ }
111
+ return false;
112
+ }
113
+ /**
114
+ * Graduated response for a bare risk level, with no notion of approval.
115
+ * Pure: no I/O, no clock, no randomness.
116
+ *
117
+ * `enforcement: 'off'` softens `confirm` to a clean pass, but MUST NOT soften
118
+ * `hard-hitl` -- that is the non-bypassable minimum this gate exists to guarantee.
119
+ */
120
+ function gradedResponse(level, enforcement) {
121
+ if (level === 'auto' || level === 'notify') {
122
+ return { blocked: false, level, requiresApproval: false, reasons: [] };
123
+ }
124
+ if (level === 'confirm') {
125
+ if (enforcement === 'off') {
126
+ return { blocked: false, level, requiresApproval: false, reasons: [] };
127
+ }
128
+ return { blocked: false, level, requiresApproval: true, reasons: [] };
129
+ }
130
+ // level === 'hard-hitl': non-bypassable, regardless of enforcement.
131
+ return { blocked: true, level, requiresApproval: true, reasons: [] };
132
+ }
133
+ /**
134
+ * @implements A-SPEC-125.2
135
+ * Pure decision function: given a risk assessment, an optional out-of-band
136
+ * approval, and an enforcement mode, decides whether the action is blocked.
137
+ *
138
+ * No I/O and no ledger writes happen here -- recording a DecisionEvent is a
139
+ * separate caller step (see decision-ledger.ts), so a ledger write failure
140
+ * can never change the gate verdict.
141
+ *
142
+ * hard-hitl is non-bypassable: only a valid Approval (actor+token+rationale,
143
+ * all non-empty) unblocks it. enforcement:'off' softens confirm to a pass,
144
+ * but never softens hard-hitl.
145
+ */
146
+ function riskGate(assessment, approval, enforcement = 'block',
147
+ // @implements A-SPEC-133 — when the caller supplies the action + timestamp, coverage is checked
148
+ // with approvalCovers (scope + expiry), so an out-of-scope or expired token no longer unblocks.
149
+ // Omitted → falls back to well-formedness (backward compatible for existing callers/tests).
150
+ cover) {
151
+ const { level, reasons } = assessment;
152
+ const authorized = cover ? approvalCovers(approval, cover.action, cover.now) : isValidApproval(approval);
153
+ if (authorized) {
154
+ if (level === 'confirm' || level === 'hard-hitl') {
155
+ return { blocked: false, level, requiresApproval: false, reasons };
156
+ }
157
+ }
158
+ const graded = gradedResponse(level, enforcement);
159
+ return { ...graded, reasons };
160
+ }
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RISK_ORDER = void 0;
4
+ exports.maxLevel = maxLevel;
5
+ exports.RISK_ORDER = ['auto', 'notify', 'confirm', 'hard-hitl'];
6
+ function maxLevel(a, b) { return exports.RISK_ORDER.indexOf(a) >= exports.RISK_ORDER.indexOf(b) ? a : b; }