@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.
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/holmes-hook-antigravity.js +31 -0
- package/bin/holmes-kit.js +23 -0
- package/bin/holmes-mcp.js +34 -0
- package/bin/holmes-stop-antigravity.js +29 -0
- package/dist/.build-id +1 -0
- package/dist/holmes/cli/agents.js +168 -0
- package/dist/holmes/cli/doctor.js +625 -0
- package/dist/holmes/cli/gitignore-merge.js +84 -0
- package/dist/holmes/cli/governed-precondition.js +157 -0
- package/dist/holmes/cli/index.js +384 -0
- package/dist/holmes/cli/init.js +462 -0
- package/dist/holmes/cli/playbook-skills.js +711 -0
- package/dist/holmes/cli/roles-readme.js +134 -0
- package/dist/holmes/cli/settings-merge.js +122 -0
- package/dist/holmes/config/config.js +70 -0
- package/dist/holmes/context/bundler.js +114 -0
- package/dist/holmes/context/render.js +29 -0
- package/dist/holmes/context/tiers.js +110 -0
- package/dist/holmes/context/tokens.js +8 -0
- package/dist/holmes/cpg/cpg-scanner.js +213 -0
- package/dist/holmes/cpg/hash-cache.js +86 -0
- package/dist/holmes/cpg/language-parser-walk.js +917 -0
- package/dist/holmes/cpg/language-parser-worker.js +81 -0
- package/dist/holmes/cpg/language-parser.js +234 -0
- package/dist/holmes/cpg/scan-cache.js +108 -0
- package/dist/holmes/cpg/source-path.js +44 -0
- package/dist/holmes/cpg/test-files.js +84 -0
- package/dist/holmes/governance/constitution-debt.js +73 -0
- package/dist/holmes/governance/constitution-report.js +25 -0
- package/dist/holmes/governance/constitution.js +129 -0
- package/dist/holmes/governance/identity.js +30 -0
- package/dist/holmes/governance/ledger-lock.js +165 -0
- package/dist/holmes/governance/ledger-store.conformance.js +90 -0
- package/dist/holmes/governance/ledger-store.js +106 -0
- package/dist/holmes/governance/progress-ledger.js +83 -0
- package/dist/holmes/governance/provenance-chain.js +365 -0
- package/dist/holmes/governance/provenance-ledger.js +0 -0
- package/dist/holmes/governance/provenance-schema.js +47 -0
- package/dist/holmes/governance/replica-id.js +106 -0
- package/dist/holmes/governance/role-policy.js +137 -0
- package/dist/holmes/governance/trust-score.js +43 -0
- package/dist/holmes/guardrail/anchors.js +31 -0
- package/dist/holmes/guardrail/blind-spots.js +38 -0
- package/dist/holmes/guardrail/decision-ledger.js +107 -0
- package/dist/holmes/guardrail/executable-artifact.js +129 -0
- package/dist/holmes/guardrail/governance-history.js +101 -0
- package/dist/holmes/guardrail/phase.js +169 -0
- package/dist/holmes/guardrail/risk-classifier.js +450 -0
- package/dist/holmes/guardrail/risk-gate.js +160 -0
- package/dist/holmes/guardrail/risk-types.js +6 -0
- package/dist/holmes/guardrail/tspec-state.js +392 -0
- package/dist/holmes/guardrail/write-target.js +224 -0
- package/dist/holmes/hooks/adapters/antigravity.js +194 -0
- package/dist/holmes/hooks/pre-tool-use.js +1262 -0
- package/dist/holmes/hooks/stop.js +416 -0
- package/dist/holmes/mcp/basis.js +162 -0
- package/dist/holmes/mcp/handlers.js +1831 -0
- package/dist/holmes/mcp/server.js +71 -0
- package/dist/holmes/mcp/stdio-client.js +165 -0
- package/dist/holmes/mcp/supervisor.js +178 -0
- package/dist/holmes/mcp/tool-schemas.js +394 -0
- package/dist/holmes/mcp/validate-args.js +281 -0
- package/dist/holmes/messages/registry.js +50 -0
- package/dist/holmes/project/baseline.js +210 -0
- package/dist/holmes/project/change-source.js +233 -0
- package/dist/holmes/project/ignore.js +145 -0
- package/dist/holmes/project/root.js +113 -0
- package/dist/holmes/reverse/anchor.js +162 -0
- package/dist/holmes/reverse/cluster.js +187 -0
- package/dist/holmes/reverse/draft.js +151 -0
- package/dist/holmes/reverse/dynamic-wiring.js +47 -0
- package/dist/holmes/reverse/scan.js +194 -0
- package/dist/holmes/reverse/surface.js +154 -0
- package/dist/holmes/reverse/test-map.js +263 -0
- package/dist/holmes/review/coverage.js +33 -0
- package/dist/holmes/review/findings.js +123 -0
- package/dist/holmes/review/package.js +40 -0
- package/dist/holmes/review/review-targets.js +92 -0
- package/dist/holmes/review/scope.js +57 -0
- package/dist/holmes/review/test-evidence.js +77 -0
- package/dist/holmes/review/test-runner.js +572 -0
- package/dist/holmes/rtm/dataflow-taint.js +262 -0
- package/dist/holmes/rtm/gap-analyzer.js +27 -0
- package/dist/holmes/rtm/git-changes.js +72 -0
- package/dist/holmes/rtm/incremental.js +45 -0
- package/dist/holmes/rtm/localize.js +100 -0
- package/dist/holmes/rtm/rtm-builder.js +191 -0
- package/dist/holmes/rtm/rtm-check.js +89 -0
- package/dist/holmes/rtm/rtm-graph.js +232 -0
- package/dist/holmes/rtm/taint.js +92 -0
- package/dist/holmes/rtm/test-scope.js +336 -0
- package/dist/holmes/spec/approval-blockers.js +204 -0
- package/dist/holmes/spec/breaking-change.js +89 -0
- package/dist/holmes/spec/legacy-format.js +87 -0
- package/dist/holmes/spec/spec-digest.js +71 -0
- package/dist/holmes/spec/spec-parser.js +106 -0
- package/dist/holmes/spec/spec-store.conformance.js +118 -0
- package/dist/holmes/spec/spec-store.js +331 -0
- package/dist/holmes/spec/spec-types.js +177 -0
- package/dist/holmes/spec/validator.js +280 -0
- package/package.json +76 -0
- package/playbooks/adopt/PLAYBOOK.md +125 -0
- package/playbooks/author-slice/PLAYBOOK.md +119 -0
- 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; }
|