@nexrall/code-core 1.3.0 → 1.4.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.
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ /**
3
+ * Test-Integrity Guard — deterministic reward-hacking detection.
4
+ *
5
+ * THE PROBLEM (unsolved by every frontier coding agent today):
6
+ * A model asked to "make the tests pass" can satisfy that objective two ways:
7
+ * (a) fix the code so the existing tests pass ← what we want
8
+ * (b) weaken the tests so they pass regardless ← reward hacking
9
+ * (b) includes: deleting assertions, adding `.skip`/`.only`/`xit`, turning a
10
+ * real assertion into a tautology (`assert True`, `expect(true).toBe(true)`),
11
+ * or commenting out / deleting whole test cases. The agent then honestly
12
+ * reports "tests pass" and the user believes the work is done. Frontier agents
13
+ * mitigate this only with prompt instructions ("don't cheat") — there is no
14
+ * deterministic detector. METR / Anthropic sabotage evals confirm every strong
15
+ * model does this measurably under pressure.
16
+ *
17
+ * THE APPROACH:
18
+ * Analyse the EDIT PAYLOAD of any write to a test file (old_string→new_string
19
+ * for edit_file / multi_edit; content for write_file). Deterministically count
20
+ * assertion / test-case / skip-marker deltas. When a test edit's NET effect is
21
+ * "fewer or weaker tests" we flag it — with a concrete, human-readable reason.
22
+ * No model output is trusted; this is pure structural analysis of the diff, so
23
+ * it cannot itself be hallucinated or gamed.
24
+ *
25
+ * This is a heuristic signal, not a proof: legitimate refactors (renaming a test,
26
+ * splitting a file) can reduce counts. So the guard never BLOCKS — it surfaces a
27
+ * one-shot nudge asking the agent to justify the change, and records it in the
28
+ * progress ledger so it survives compaction. That converts a silent, invisible
29
+ * failure into an explicit, reviewable decision.
30
+ */
31
+ Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.isTestFile = isTestFile;
33
+ exports.analyzeTestEdit = analyzeTestEdit;
34
+ exports.analyzeWriteToolForTestIntegrity = analyzeWriteToolForTestIntegrity;
35
+ // ── Is this a test file? ──────────────────────────────────────────────────────
36
+ // Covers the common conventions across JS/TS, Python, Go, Java/Kotlin, Ruby,
37
+ // Rust, PHP, C#. Path-based (fast, language-agnostic).
38
+ const TEST_PATH_RE = /(?:^|[\/\\])(?:tests?|spec|__tests__|testing)[\/\\]|(?:\.|_|-)(?:test|spec|tests)\.[a-z]+$|(?:^|[\/\\])test_[^\/\\]+\.py$|(?:^|[\/\\])[^\/\\]+_test\.(?:go|py|rb)$|Test[^\/\\]*\.(?:java|kt|cs)$|(?:^|[\/\\])[^\/\\]*Tests?\.(?:java|kt|cs)$/i;
39
+ function isTestFile(path) {
40
+ if (!path || typeof path !== 'string')
41
+ return false;
42
+ return TEST_PATH_RE.test(path);
43
+ }
44
+ // ── Token patterns ────────────────────────────────────────────────────────────
45
+ // Assertion-bearing tokens across frameworks. We count occurrences, not parse.
46
+ const ASSERTION_RES = [
47
+ /\bexpect\s*\(/g, // jest / chai / vitest / jasmine
48
+ /\bassert(?:Equals?|True|False|That|Null|NotNull|Same)?\b/g, // junit / python unittest / generic
49
+ /\bassert\s*[.(]/g, // node:assert (assert.equal / assert( ) )
50
+ /\.should\b/g, // chai should / rspec should
51
+ /\bshould\s*[.(]/g,
52
+ /\bEXPECT_[A-Z]+\s*\(/g, // gtest
53
+ /\bASSERT_[A-Z]+\s*\(/g, // gtest
54
+ /\brequire\.\w+\s*\(/g, // testify require
55
+ /\bassert\.\w+\s*\(/g, // testify assert
56
+ /\bt\.(?:Error|Fatal|Errorf|Fatalf)\b/g, // go testing
57
+ /\bassert!\s*\(|\bassert_eq!\s*\(|\bassert_ne!\s*\(/g, // rust
58
+ ];
59
+ // Test-case declaration tokens (a "test" unit).
60
+ const TESTCASE_RES = [
61
+ /\b(?:it|test)\s*\(/g, // jest / mocha / jasmine
62
+ /\bdef\s+test_\w+/g, // python
63
+ /\bfunc\s+Test\w+/g, // go
64
+ /@Test\b/g, // junit
65
+ /\bfn\s+\w*test\w*\s*\(/gi, // rust (best-effort)
66
+ ];
67
+ // Skip / focus markers — presence in NEW but absence in OLD is a strong signal.
68
+ const SKIP_RES = [
69
+ /\b(?:it|test|describe|context|suite)\s*\.\s*(?:skip|only)\b/g,
70
+ /\bx(?:it|describe|test|context)\s*\(/g, // xit / xdescribe
71
+ /\bf(?:it|describe)\s*\(/g, // fit / fdescribe (focus)
72
+ /@pytest\.mark\.skip\b|@pytest\.mark\.skipif\b|@unittest\.skip\b|@skip\b/g,
73
+ /\bpytest\.skip\s*\(/g,
74
+ /\bt\.Skip\s*\(|\bt\.SkipNow\s*\(/g, // go
75
+ /@Disabled\b|@Ignore\b/g, // junit / kotlin
76
+ /\.only\s*\(/g, // test.only leaks CI coverage
77
+ ];
78
+ // Tautological assertions — always-true, structurally meaningless.
79
+ const TAUTOLOGY_RES = [
80
+ /\bassert\s+True\b|\bassert\s+1\b|\bassert\s+not\s+False\b/g, // python
81
+ /\bassert\s*\(\s*true\s*\)|\bassert\.ok\s*\(\s*true\s*\)/gi, // node
82
+ /\bexpect\s*\(\s*true\s*\)\s*\.\s*to(?:Be|Equal|BeTruthy)\s*\(\s*true\s*\)?/gi, // jest
83
+ /\bexpect\s*\(\s*(\w+)\s*\)\s*\.\s*toBe\s*\(\s*\1\s*\)/g, // expect(x).toBe(x)
84
+ /\bassert_eq!\s*\(\s*true\s*,\s*true\s*\)/g, // rust
85
+ /\bassert!\s*\(\s*true\s*\)/g,
86
+ ];
87
+ function countMatches(res, text) {
88
+ let n = 0;
89
+ for (const re of res) {
90
+ re.lastIndex = 0;
91
+ const m = text.match(re);
92
+ if (m)
93
+ n += m.length;
94
+ }
95
+ return n;
96
+ }
97
+ // Count lines that newly comment out something containing an assertion/test kw.
98
+ function countCommentedOutTestLines(oldText, newText) {
99
+ const oldLines = new Set(oldText.split('\n').map((l) => l.trim()));
100
+ let n = 0;
101
+ for (const raw of newText.split('\n')) {
102
+ const line = raw.trim();
103
+ const isComment = /^(?:\/\/|#|\/\*|\*)/.test(line);
104
+ if (!isComment)
105
+ continue;
106
+ const body = line.replace(/^(?:\/\/+|#+|\/\*+|\*+)\s?/, '');
107
+ // Only count if the uncommented body looks like a test/assertion AND that
108
+ // exact code line existed (uncommented) in the old version.
109
+ const looksLikeTest = /\b(expect|assert|it\(|test\(|def test_|func Test|EXPECT_|ASSERT_)\b/.test(body);
110
+ if (looksLikeTest && oldLines.has(body))
111
+ n += 1;
112
+ }
113
+ return n;
114
+ }
115
+ /**
116
+ * Analyse a single test-file edit (old fragment → new fragment). For write_file
117
+ * pass ('', fullNewContent) — only additive signals (skips, tautologies) fire.
118
+ */
119
+ function analyzeTestEdit(path, oldText, newText) {
120
+ if (!isTestFile(path)) {
121
+ return { isTestFile: false, suspicious: false, findings: [] };
122
+ }
123
+ const findings = [];
124
+ const skipsBefore = countMatches(SKIP_RES, oldText);
125
+ const skipsAfter = countMatches(SKIP_RES, newText);
126
+ if (skipsAfter > skipsBefore) {
127
+ findings.push({
128
+ kind: 'skip-added',
129
+ reason: `added ${skipsAfter - skipsBefore} skip/only/disabled marker(s) — tests are being skipped, not fixed`,
130
+ });
131
+ }
132
+ const tautoBefore = countMatches(TAUTOLOGY_RES, oldText);
133
+ const tautoAfter = countMatches(TAUTOLOGY_RES, newText);
134
+ if (tautoAfter > tautoBefore) {
135
+ findings.push({
136
+ kind: 'tautology-added',
137
+ reason: `added ${tautoAfter - tautoBefore} always-true assertion(s) (e.g. assert True / expect(x).toBe(x)) — the check no longer tests anything`,
138
+ });
139
+ }
140
+ const assertBefore = countMatches(ASSERTION_RES, oldText);
141
+ const assertAfter = countMatches(ASSERTION_RES, newText);
142
+ if (assertAfter < assertBefore) {
143
+ findings.push({
144
+ kind: 'assertion-removed',
145
+ reason: `removed ${assertBefore - assertAfter} assertion(s) — the test verifies less than before`,
146
+ });
147
+ }
148
+ const casesBefore = countMatches(TESTCASE_RES, oldText);
149
+ const casesAfter = countMatches(TESTCASE_RES, newText);
150
+ if (casesAfter < casesBefore) {
151
+ findings.push({
152
+ kind: 'testcase-removed',
153
+ reason: `removed ${casesBefore - casesAfter} test case(s)`,
154
+ });
155
+ }
156
+ const commented = countCommentedOutTestLines(oldText, newText);
157
+ if (commented > 0) {
158
+ findings.push({
159
+ kind: 'body-commented',
160
+ reason: `commented out ${commented} line(s) of previously-active test/assertion code`,
161
+ });
162
+ }
163
+ return { isTestFile: true, suspicious: findings.length > 0, findings };
164
+ }
165
+ /**
166
+ * Extract the (old, new) text fragments to analyse from a write-tool input.
167
+ * Returns null for non-test files or tools we can't inspect.
168
+ *
169
+ * - edit_file: { old_string, new_string }
170
+ * - multi_edit: concatenate all edits' old / new
171
+ * - write_file: ('', content) — additive-only detection (no prior content here)
172
+ */
173
+ function analyzeWriteToolForTestIntegrity(toolName, input) {
174
+ if (!input)
175
+ return null;
176
+ const path = typeof input.path === 'string' ? input.path
177
+ : typeof input.file_path === 'string' ? input.file_path
178
+ : '';
179
+ if (!isTestFile(path))
180
+ return null;
181
+ if (toolName === 'edit_file') {
182
+ const oldText = typeof input.old_string === 'string' ? input.old_string : '';
183
+ const newText = typeof input.new_string === 'string' ? input.new_string : '';
184
+ return analyzeTestEdit(path, oldText, newText);
185
+ }
186
+ if (toolName === 'multi_edit' && Array.isArray(input.edits)) {
187
+ const edits = input.edits;
188
+ const oldText = edits.map((e) => (typeof e.old_string === 'string' ? e.old_string : '')).join('\n');
189
+ const newText = edits.map((e) => (typeof e.new_string === 'string' ? e.new_string : '')).join('\n');
190
+ return analyzeTestEdit(path, oldText, newText);
191
+ }
192
+ if (toolName === 'write_file') {
193
+ const content = typeof input.content === 'string' ? input.content : '';
194
+ // No prior content available at this layer → additive-only ('' → content).
195
+ return analyzeTestEdit(path, '', content);
196
+ }
197
+ return null;
198
+ }
199
+ //# sourceMappingURL=testIntegrity.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,UAAU,EAA8B,UAAU,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAKrH,eAAO,MAAM,QAAQ,4BAA4B,CAAC;AAiBlD,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB,aAAa,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC;IACnC,oFAAoF;IACpF,UAAU,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IACjG,6EAA6E;IAC7E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAWD,wBAAsB,UAAU,CAC9B,QAAQ,EAAE,OAAO,EAAE,EACnB,OAAO,EAAE,iBAAiB,EAC1B,OAAO,EAAE,CAAC,CAAC,EAAE,QAAQ,KAAK,IAAI,GAC7B,OAAO,CAAC,OAAO,CAAC,CA0WlB;AAID,wBAAsB,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,CAalD;AAID,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAgB1E;AAID,wBAAsB,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAkBhF"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,UAAU,EAA8B,UAAU,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAKrH,eAAO,MAAM,QAAQ,4BAA4B,CAAC;AAiBlD,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB,aAAa,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC;IACnC,oFAAoF;IACpF,UAAU,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IACjG,6EAA6E;IAC7E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAWD,wBAAsB,UAAU,CAC9B,QAAQ,EAAE,OAAO,EAAE,EACnB,OAAO,EAAE,iBAAiB,EAC1B,OAAO,EAAE,CAAC,CAAC,EAAE,QAAQ,KAAK,IAAI,GAC7B,OAAO,CAAC,OAAO,CAAC,CA8WlB;AAID,wBAAsB,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,CAalD;AAID,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAgB1E;AAID,wBAAsB,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAkBhF"}
@@ -75,6 +75,10 @@ async function streamChat(messages, options, onEvent) {
75
75
  await sleep(RETRY_BASE_MS * Math.pow(2, attempt));
76
76
  continue;
77
77
  }
78
+ // 4xx (incl. 413 Payload Too Large, 400 Bad Request, 402 Insufficient balance)
79
+ // are DETERMINISTIC given the same body — retrying resends the identical oversized
80
+ // conversation and can never succeed. Fall through to the non-retryable !response.ok
81
+ // handler below instead of burning retries (this was the 16-request "retry storm").
78
82
  break; // success or non-retryable error
79
83
  }
80
84
  catch (err) {
package/dist/index.d.ts CHANGED
@@ -3,6 +3,10 @@ export * from './auth/index';
3
3
  export * from './api/client';
4
4
  export * from './tools/executor';
5
5
  export * from './agent/loop';
6
+ export * from './agent/testIntegrity';
7
+ export * from './agent/editCompleteness';
8
+ export * from './agent/crossFile';
9
+ export * from './agent/flaky';
6
10
  export * from './mcp/client';
7
11
  export * from './mcp/httpClient';
8
12
  export * from './mcp/manager';
@@ -10,6 +14,7 @@ export * from './checkpoint/manager';
10
14
  export * from './commands/loader';
11
15
  export * from './agent/agentTypes';
12
16
  export * from './permissions/rules';
17
+ export * from './permissions/destructive';
13
18
  export * from './plugins/index';
14
19
  export * from './plugins/installer';
15
20
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAChC,cAAc,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,mBAAmB,CAAC;AAClC,cAAc,eAAe,CAAC;AAC9B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,iBAAiB,CAAC;AAChC,cAAc,qBAAqB,CAAC"}
package/dist/index.js CHANGED
@@ -19,6 +19,10 @@ __exportStar(require("./auth/index"), exports);
19
19
  __exportStar(require("./api/client"), exports);
20
20
  __exportStar(require("./tools/executor"), exports);
21
21
  __exportStar(require("./agent/loop"), exports);
22
+ __exportStar(require("./agent/testIntegrity"), exports);
23
+ __exportStar(require("./agent/editCompleteness"), exports);
24
+ __exportStar(require("./agent/crossFile"), exports);
25
+ __exportStar(require("./agent/flaky"), exports);
22
26
  __exportStar(require("./mcp/client"), exports);
23
27
  __exportStar(require("./mcp/httpClient"), exports);
24
28
  __exportStar(require("./mcp/manager"), exports);
@@ -26,6 +30,7 @@ __exportStar(require("./checkpoint/manager"), exports);
26
30
  __exportStar(require("./commands/loader"), exports);
27
31
  __exportStar(require("./agent/agentTypes"), exports);
28
32
  __exportStar(require("./permissions/rules"), exports);
33
+ __exportStar(require("./permissions/destructive"), exports);
29
34
  __exportStar(require("./plugins/index"), exports);
30
35
  __exportStar(require("./plugins/installer"), exports);
31
36
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Destructive-command classifier.
3
+ *
4
+ * `rm -rf /`, `dd`, `mkfs` are DENIED outright by executor.ts BLOCKED_REGEXES —
5
+ * they are never legitimate. This module covers the OTHER class: commands that
6
+ * are perfectly legitimate but IRREVERSIBLE and high-blast-radius — dropping a
7
+ * database, force-pushing over history, `terraform destroy`, `kubectl delete`,
8
+ * `aws s3 rb`, `docker system prune`. These must never run silently.
9
+ *
10
+ * This is the class of action that has caused real production incidents (an
11
+ * agent in "auto-approve everything" mode wiping a prod database). So a positive
12
+ * classification here forces an EXPLICIT confirmation that CANNOT be bypassed by
13
+ * a blanket "approve all" / auto-approve session flag — the user must confirm
14
+ * THIS specific command.
15
+ *
16
+ * Matching runs against the command with heredoc bodies and quoted strings
17
+ * stripped (stripDataSections), so a commit message, README, or echo that merely
18
+ * MENTIONS "DROP TABLE" or "terraform destroy" does not trip the guard — only a
19
+ * real command position does. This mirrors the tier-2 approach in executor.ts
20
+ * that eliminated the false-positive storms.
21
+ */
22
+ export interface DestructiveMatch {
23
+ /** Coarse category, e.g. 'database' | 'git-history' | 'infra' | 'cloud' | 'container'. */
24
+ category: string;
25
+ /** Short human-readable reason shown in the confirmation prompt. */
26
+ reason: string;
27
+ }
28
+ /**
29
+ * Classify a bash command. Returns the FIRST matching destructive category, or
30
+ * null when the command is not recognised as destructive.
31
+ *
32
+ * Two passes with different data-stripping:
33
+ * - RAW_RULES run on the heredoc-stripped but quote-PRESERVING string, because
34
+ * real SQL lives inside quotes (`psql -c "DROP TABLE"`); they are gated on a
35
+ * DB-client token so a mention can't match.
36
+ * - STRIPPED_RULES run on the fully stripped string (quotes + heredocs gone) so
37
+ * mentions inside commit messages / echoes / grep patterns / heredoc'd docs
38
+ * never trip the bare-command rules (git/infra/cloud/container/publish).
39
+ */
40
+ export declare function classifyDestructiveCommand(command: string): DestructiveMatch | null;
41
+ /** True when the given tool + input represents a destructive bash command. */
42
+ export declare function isDestructiveBash(tool: string, input: Record<string, unknown>): DestructiveMatch | null;
43
+ //# sourceMappingURL=destructive.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"destructive.d.ts","sourceRoot":"","sources":["../../src/permissions/destructive.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,MAAM,WAAW,gBAAgB;IAC/B,0FAA0F;IAC1F,QAAQ,EAAE,MAAM,CAAC;IACjB,oEAAoE;IACpE,MAAM,EAAE,MAAM,CAAC;CAChB;AAsMD;;;;;;;;;;;GAWG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,GAAG,gBAAgB,GAAG,IAAI,CAenF;AAED,8EAA8E;AAC9E,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,gBAAgB,GAAG,IAAI,CAQvG"}
@@ -0,0 +1,244 @@
1
+ "use strict";
2
+ /**
3
+ * Destructive-command classifier.
4
+ *
5
+ * `rm -rf /`, `dd`, `mkfs` are DENIED outright by executor.ts BLOCKED_REGEXES —
6
+ * they are never legitimate. This module covers the OTHER class: commands that
7
+ * are perfectly legitimate but IRREVERSIBLE and high-blast-radius — dropping a
8
+ * database, force-pushing over history, `terraform destroy`, `kubectl delete`,
9
+ * `aws s3 rb`, `docker system prune`. These must never run silently.
10
+ *
11
+ * This is the class of action that has caused real production incidents (an
12
+ * agent in "auto-approve everything" mode wiping a prod database). So a positive
13
+ * classification here forces an EXPLICIT confirmation that CANNOT be bypassed by
14
+ * a blanket "approve all" / auto-approve session flag — the user must confirm
15
+ * THIS specific command.
16
+ *
17
+ * Matching runs against the command with heredoc bodies and quoted strings
18
+ * stripped (stripDataSections), so a commit message, README, or echo that merely
19
+ * MENTIONS "DROP TABLE" or "terraform destroy" does not trip the guard — only a
20
+ * real command position does. This mirrors the tier-2 approach in executor.ts
21
+ * that eliminated the false-positive storms.
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.classifyDestructiveCommand = classifyDestructiveCommand;
25
+ exports.isDestructiveBash = isDestructiveBash;
26
+ // ── Data-section stripping (naive; feeds a heuristic, not an executor) ────────
27
+ function stripHeredocs(command) {
28
+ return command.replace(/<<-?\s*(['"]?)(\w+)\1[\s\S]*?\n\2(?=\s|;|&|$)/g, ' ');
29
+ }
30
+ function stripDataSections(command) {
31
+ return stripHeredocs(command)
32
+ .replace(/"(?:[^"\\]|\\[\s\S])*"/g, '""')
33
+ .replace(/'[^']*'/g, "''");
34
+ }
35
+ // A command "position" is start-of-string/line or right after a shell separator
36
+ // (; & | && || ` ( $( ) or a privilege / interpreter prefix. Reused as a prefix
37
+ // so rules only fire on a real invocation, never on an argument or mention.
38
+ const CMD_POS = '(?:^|[;&|`\\n(]|\\$\\(|\\bsudo\\s+|\\bdoas\\s+|\\bexec\\s+|\\bnohup\\s+|\\bxargs\\s+(?:-\\S+\\s+)*|\\btime\\s+|\\bnice\\s+(?:-\\S+\\s+)*)\\s*';
39
+ // SQL/DB clients that indicate the surrounding text is EXECUTED SQL, not a
40
+ // mention. Real SQL almost always lives inside quotes (`psql -c "DROP TABLE"`),
41
+ // so DB rules run on the quote-PRESERVING string (RAW_RULES) — but they must be
42
+ // gated on one of these client tokens at a command position, otherwise a
43
+ // `git commit -m "drop table logic"` mention would trip them.
44
+ const DB_CLIENT = '(?:psql|mysql|mariadb|mysqldump|mongo|mongosh|sqlite3|cockroach|clickhouse-client|pgcli|mycli|usql|sqlcmd)';
45
+ // ── DB rules: matched on the heredoc-stripped (but quote-PRESERVING) string ──
46
+ // Each is gated on a DB client so mentions in commit messages / echoes / grep
47
+ // patterns (which get quote-stripped for the STRIPPED_RULES pass) don't match.
48
+ const RAW_RULES = [
49
+ {
50
+ category: 'database',
51
+ reason: 'drops a database, table, or schema (irreversible data loss)',
52
+ re: new RegExp(CMD_POS + DB_CLIENT + String.raw `\b[\s\S]*?\bdrop\s+(?:database|schema|table)\b`, 'i'),
53
+ },
54
+ {
55
+ category: 'database',
56
+ reason: 'truncates a table (deletes all rows, irreversible)',
57
+ re: new RegExp(CMD_POS + DB_CLIENT + String.raw `\b[\s\S]*?\btruncate\s+(?:table\s+)?\w`, 'i'),
58
+ },
59
+ {
60
+ category: 'database',
61
+ reason: 'DELETE without a WHERE clause (deletes every row)',
62
+ re: new RegExp(CMD_POS + DB_CLIENT + String.raw `\b[\s\S]*?\bdelete\s+from\s+[\w.` + '`' + String.raw `"]+\s*(?:;|"|'|$)(?![\s\S]*\bwhere\b)`, 'i'),
63
+ },
64
+ {
65
+ category: 'database',
66
+ reason: 'drops a Postgres/MySQL role or user',
67
+ re: new RegExp(CMD_POS + DB_CLIENT + String.raw `\b[\s\S]*?\bdrop\s+(?:role|user)\b`, 'i'),
68
+ },
69
+ {
70
+ category: 'database',
71
+ reason: 'drops a MongoDB database or collection',
72
+ re: /\b(?:mongo|mongosh)\b[\s\S]*?\.(?:drop|dropDatabase)\s*\(\s*\)/i,
73
+ },
74
+ {
75
+ category: 'database',
76
+ reason: 'flushes all Redis keys (wipes the datastore)',
77
+ re: new RegExp(CMD_POS + String.raw `redis-cli\b[\s\S]*?\bflush(?:all|db)\b`, 'i'),
78
+ },
79
+ ];
80
+ // Each `re` is expected to be anchored with CMD_POS where relevant. Case-insensitive.
81
+ const STRIPPED_RULES = [
82
+ // ── Migration reset verbs (bare commands, not quoted SQL) ───────────────────
83
+ {
84
+ category: 'database',
85
+ reason: 'reverses or resets database migrations (may drop columns/tables)',
86
+ re: new RegExp(CMD_POS + String.raw `(?:rails\s+db:(?:drop|reset)|rake\s+db:(?:drop|reset)|(?:npx\s+)?prisma\s+migrate\s+reset|(?:npx\s+)?sequelize(?:-cli)?\s+db:drop|python\s+manage\.py\s+flush|knex\s+migrate:rollback\s+--all|alembic\s+downgrade\s+base)`, 'i'),
87
+ },
88
+ // ── Git history / remotes ───────────────────────────────────────────────────
89
+ {
90
+ category: 'git-history',
91
+ reason: 'force-pushes (can overwrite/destroy remote history)',
92
+ re: /\bgit\s+push\b[^\n]*\s(?:--force\b(?!-with-lease)|-f\b)/i,
93
+ },
94
+ {
95
+ category: 'git-history',
96
+ reason: 'deletes a remote branch',
97
+ re: /\bgit\s+push\b[^\n]*\s:(?:refs\/heads\/)?\S/i,
98
+ },
99
+ {
100
+ category: 'git-history',
101
+ reason: 'hard-resets the working tree (discards uncommitted work)',
102
+ re: /\bgit\s+reset\s+(?:--hard|--keep)\b/i,
103
+ },
104
+ {
105
+ category: 'git-history',
106
+ reason: 'deletes untracked files (git clean)',
107
+ re: /\bgit\s+clean\s+-[a-z]*f/i,
108
+ },
109
+ {
110
+ category: 'git-history',
111
+ reason: 'force-deletes a git branch',
112
+ re: /\bgit\s+branch\s+-[a-z]*D/i,
113
+ },
114
+ {
115
+ category: 'git-history',
116
+ reason: 'rewrites git history (filter-branch/filter-repo)',
117
+ re: /\bgit\s+(?:filter-branch|filter-repo)\b/i,
118
+ },
119
+ {
120
+ category: 'git-history',
121
+ reason: 'reflog expire / gc prune (permanently drops recoverable commits)',
122
+ re: /\bgit\s+(?:reflog\s+expire|gc\s+[^\n]*--prune=(?:now|all))\b/i,
123
+ },
124
+ // ── Infrastructure as code ──────────────────────────────────────────────────
125
+ {
126
+ category: 'infra',
127
+ reason: 'destroys Terraform-managed infrastructure',
128
+ re: /\bterraform\s+(?:destroy|apply)\b[^\n]*(?:-auto-approve|destroy)/i,
129
+ },
130
+ {
131
+ category: 'infra',
132
+ reason: 'terraform destroy',
133
+ re: /\bterraform\s+destroy\b/i,
134
+ },
135
+ {
136
+ category: 'infra',
137
+ reason: 'destroys Pulumi-managed infrastructure',
138
+ re: /\bpulumi\s+destroy\b/i,
139
+ },
140
+ {
141
+ category: 'infra',
142
+ reason: 'deletes Kubernetes resources',
143
+ re: /\bkubectl\s+delete\b/i,
144
+ },
145
+ {
146
+ category: 'infra',
147
+ reason: 'helm uninstall / delete (removes a release)',
148
+ re: /\bhelm\s+(?:uninstall|delete)\b/i,
149
+ },
150
+ // ── Cloud CLIs ──────────────────────────────────────────────────────────────
151
+ {
152
+ category: 'cloud',
153
+ reason: 'removes an S3 bucket or recursively deletes objects',
154
+ re: /\baws\s+s3\s+(?:rb\b|rm\b[^\n]*--recursive)/i,
155
+ },
156
+ {
157
+ category: 'cloud',
158
+ reason: 'deletes/terminates an AWS resource',
159
+ re: /\baws\s+\w[\w-]*\s+(?:delete-\w+|terminate-instances|delete\b|remove-\w+)/i,
160
+ },
161
+ {
162
+ category: 'cloud',
163
+ reason: 'deletes a GCP resource',
164
+ re: /\bgcloud\s+[\w-]+(?:\s+[\w-]+)*\s+delete\b/i,
165
+ },
166
+ {
167
+ category: 'cloud',
168
+ reason: 'deletes an Azure resource',
169
+ re: /\baz\s+[\w-]+(?:\s+[\w-]+)*\s+delete\b/i,
170
+ },
171
+ {
172
+ category: 'cloud',
173
+ reason: 'gsutil recursive remove (deletes cloud objects/buckets)',
174
+ re: /\bgsutil\s+(?:-m\s+)?rm\b[^\n]*-[a-z]*r/i,
175
+ },
176
+ {
177
+ category: 'cloud',
178
+ reason: 'wrangler delete (removes a Cloudflare Worker/resource)',
179
+ re: /\bwrangler\s+(?:delete|d1\s+delete|r2\s+bucket\s+delete|kv:namespace\s+delete)\b/i,
180
+ },
181
+ // ── Containers / volumes ────────────────────────────────────────────────────
182
+ {
183
+ category: 'container',
184
+ reason: 'docker system/volume prune (removes volumes & data)',
185
+ re: /\bdocker\s+(?:system|volume|image|container)\s+prune\b/i,
186
+ },
187
+ {
188
+ category: 'container',
189
+ reason: 'removes docker volumes (data loss)',
190
+ re: /\bdocker\s+volume\s+rm\b/i,
191
+ },
192
+ {
193
+ category: 'container',
194
+ reason: 'docker-compose down with -v (deletes named volumes)',
195
+ re: /\bdocker[\s-]compose\s+down\b[^\n]*\s-v\b|--volumes\b/i,
196
+ },
197
+ // ── Irreversible publishes ──────────────────────────────────────────────────
198
+ {
199
+ category: 'publish',
200
+ reason: 'publishes a package to a public registry (cannot be unpublished)',
201
+ re: new RegExp(CMD_POS + String.raw `(?:npm\s+publish|yarn\s+publish|pnpm\s+publish|cargo\s+publish|twine\s+upload|gem\s+push)\b`, 'i'),
202
+ },
203
+ ];
204
+ /**
205
+ * Classify a bash command. Returns the FIRST matching destructive category, or
206
+ * null when the command is not recognised as destructive.
207
+ *
208
+ * Two passes with different data-stripping:
209
+ * - RAW_RULES run on the heredoc-stripped but quote-PRESERVING string, because
210
+ * real SQL lives inside quotes (`psql -c "DROP TABLE"`); they are gated on a
211
+ * DB-client token so a mention can't match.
212
+ * - STRIPPED_RULES run on the fully stripped string (quotes + heredocs gone) so
213
+ * mentions inside commit messages / echoes / grep patterns / heredoc'd docs
214
+ * never trip the bare-command rules (git/infra/cloud/container/publish).
215
+ */
216
+ function classifyDestructiveCommand(command) {
217
+ if (!command || typeof command !== 'string')
218
+ return null;
219
+ const raw = stripHeredocs(command);
220
+ for (const rule of RAW_RULES) {
221
+ if (rule.re.test(raw)) {
222
+ return { category: rule.category, reason: rule.reason };
223
+ }
224
+ }
225
+ const stripped = stripDataSections(command);
226
+ for (const rule of STRIPPED_RULES) {
227
+ if (rule.re.test(stripped)) {
228
+ return { category: rule.category, reason: rule.reason };
229
+ }
230
+ }
231
+ return null;
232
+ }
233
+ /** True when the given tool + input represents a destructive bash command. */
234
+ function isDestructiveBash(tool, input) {
235
+ if (tool !== 'bash' && tool !== 'run_command' && tool !== 'execute_command')
236
+ return null;
237
+ const command = typeof input.command === 'string'
238
+ ? input.command
239
+ : typeof input.cmd === 'string'
240
+ ? input.cmd
241
+ : '';
242
+ return classifyDestructiveCommand(command);
243
+ }
244
+ //# sourceMappingURL=destructive.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"executor.d.ts","sourceRoot":"","sources":["../../src/tools/executor.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAC3C,OAAO,EAAyB,KAAK,aAAa,EAAE,MAAM,WAAW,CAAC;AA6qDtE,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,WAAW,CAAC,EAAE;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,EAClC,OAAO,CAAC,EAAE,aAAa,EACvB,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,UAAU,CAAC,CAiBrB"}
1
+ {"version":3,"file":"executor.d.ts","sourceRoot":"","sources":["../../src/tools/executor.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAC3C,OAAO,EAAyB,KAAK,aAAa,EAAE,MAAM,WAAW,CAAC;AAy8DtE,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,WAAW,CAAC,EAAE;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,EAClC,OAAO,CAAC,EAAE,aAAa,EACvB,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,UAAU,CAAC,CAiBrB"}