@clear-capabilities/agentic-security-scanner 0.77.0 → 0.79.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 (69) hide show
  1. package/bin/.agentic-security/findings.json +1907 -0
  2. package/bin/.agentic-security/last-scan.json +1907 -0
  3. package/bin/.agentic-security/last-scan.json.sig +1 -0
  4. package/bin/.agentic-security/scan-history.json +166 -0
  5. package/bin/.agentic-security/streak.json +20 -0
  6. package/bin/agentic-security.js +55 -9
  7. package/dist/178.index.js +1 -1
  8. package/dist/384.index.js +1 -1
  9. package/dist/476.index.js +5 -5
  10. package/dist/637.index.js +1 -1
  11. package/dist/700.index.js +138 -0
  12. package/dist/718.index.js +159 -0
  13. package/dist/824.index.js +126 -0
  14. package/dist/838.index.js +1 -1
  15. package/dist/985.index.js +5 -0
  16. package/dist/agentic-security.mjs +32 -32
  17. package/dist/agentic-security.mjs.sha256 +1 -1
  18. package/package.json +4 -4
  19. package/src/dataflow/async-sequencing.js +16 -7
  20. package/src/dataflow/builtin-summaries.js +131 -0
  21. package/src/dataflow/catalog.js +107 -0
  22. package/src/dataflow/cross-repo.js +75 -1
  23. package/src/dataflow/engine.js +181 -8
  24. package/src/dataflow/implicit-flow.js +24 -6
  25. package/src/dataflow/stub-aware-filter.js +69 -11
  26. package/src/dataflow/summaries.js +28 -3
  27. package/src/engine-parallel.js +70 -0
  28. package/src/engine.js +270 -19
  29. package/src/integrations/index.js +2 -1
  30. package/src/ir/callgraph.js +27 -7
  31. package/src/ir/index.js +22 -1
  32. package/src/ir/parser-go.js +403 -0
  33. package/src/ir/parser-js.js +2 -0
  34. package/src/ir/parser-php.js +330 -0
  35. package/src/ir/parser-py.helper.py +137 -11
  36. package/src/ir/parser-rb.js +309 -0
  37. package/src/llm-validator/index.js +7 -5
  38. package/src/mcp/audit.js +5 -0
  39. package/src/posture/calibration-drift.js +2 -1
  40. package/src/posture/calibration.js +16 -1
  41. package/src/posture/fix-history.js +8 -2
  42. package/src/posture/profile.js +4 -5
  43. package/src/posture/rule-overrides.js +2 -3
  44. package/src/posture/rule-pack-signing.js +2 -3
  45. package/src/posture/rule-synthesis.js +5 -6
  46. package/src/posture/security-trend.js +4 -7
  47. package/src/posture/state-dir.js +124 -0
  48. package/src/posture/streak.js +3 -0
  49. package/src/posture/suppressions.js +5 -8
  50. package/src/posture/triage.js +16 -5
  51. package/src/posture/validator-metrics.js +3 -6
  52. package/src/report/index.js +23 -2
  53. package/src/sast/cache-poisoning.js +77 -0
  54. package/src/sast/comparison-safety.js +73 -0
  55. package/src/sast/db-taint.js +78 -0
  56. package/src/sast/graphql.js +127 -0
  57. package/src/sast/llm-stored-prompt.js +57 -0
  58. package/src/sast/mutation-xss.js +43 -0
  59. package/src/sast/nosql-injection.js +5 -0
  60. package/src/sast/null-byte-injection.js +76 -0
  61. package/src/sast/redos-nfa.js +338 -0
  62. package/src/sast/rust.js +26 -0
  63. package/src/sast/sensitive-data-logging.js +73 -0
  64. package/src/sast/weak-password-hash.js +77 -0
  65. package/src/sast/weak-randomness.js +100 -0
  66. package/src/sca/binary-metadata.js +124 -0
  67. package/src/sca/llm-function-extract.js +107 -0
  68. package/src/sca/py-package-functions.js +118 -0
  69. package/src/sca/vendor-detect.js +144 -0
@@ -0,0 +1,309 @@
1
+ // Ruby IR frontend.
2
+ //
3
+ // Regex-based, follows the parser-cs.js / parser-go.js pattern. Focused on
4
+ // Rails params, ActiveRecord, Kernel methods surface area.
5
+ //
6
+ // What we model:
7
+ // - def / def self. method declarations
8
+ // - var = expr assignments
9
+ // - method calls: obj.method(args) and method(args)
10
+ // - return
11
+ // - each/map/select blocks as loop-header
12
+ //
13
+ // What we do NOT model:
14
+ // - blocks / procs / lambdas as first-class values
15
+ // - metaprogramming (define_method, method_missing)
16
+ // - module_function / protected / private method visibility scoping
17
+ // - control flow (if/unless/while/until/case) — body is straight-line
18
+ //
19
+ // Ruby body extraction: count def/class/module/do/if/unless/while/until/
20
+ // for/case/begin as openers and `end` as closers. Return null on balance
21
+ // failure (heredocs, multi-line strings can confuse the regex parser).
22
+
23
+ import * as crypto from 'node:crypto';
24
+
25
+ const DEF_RE = /(?:^|\n)\s*def\s+(?:self\.)?(\w+[?!=]?)\s*(?:\(([^)]*)\))?/g;
26
+
27
+ function _extractRubyBody(src, defEnd) {
28
+ let depth = 1;
29
+ let i = defEnd;
30
+ let inStr = null;
31
+ let escape = false;
32
+ const openers = /\b(?:def|class|module|do|if|unless|while|until|for|case|begin)\b/;
33
+ while (i < src.length && depth > 0) {
34
+ const c = src[i];
35
+ if (escape) { escape = false; i++; continue; }
36
+ if (inStr) {
37
+ if (c === '\\') { escape = true; i++; continue; }
38
+ if (c === inStr) inStr = null;
39
+ i++; continue;
40
+ }
41
+ if (c === '"' || c === '\'') { inStr = c; i++; continue; }
42
+ if (c === '#') {
43
+ while (i < src.length && src[i] !== '\n') i++;
44
+ continue;
45
+ }
46
+ // Check for keyword boundaries
47
+ if (/[a-z]/i.test(c)) {
48
+ let word = '';
49
+ const start = i;
50
+ while (i < src.length && /\w/.test(src[i])) { word += src[i]; i++; }
51
+ if (word === 'end' && (start === 0 || /[^.\w]/.test(src[start - 1] || ' '))) {
52
+ depth--;
53
+ } else if (openers.test(word) && (start === 0 || /[^.\w]/.test(src[start - 1] || ' '))) {
54
+ // Only count as opener if not preceded by . (e.g., x.if would be wrong but rare)
55
+ depth++;
56
+ }
57
+ continue;
58
+ }
59
+ i++;
60
+ }
61
+ if (depth !== 0) return null;
62
+ // `end` keyword ends at position `i`; body is between defEnd and the start of `end`
63
+ return { body: src.slice(defEnd, i - 3).trimEnd(), end: i };
64
+ }
65
+
66
+ const _RB_OPENERS = /^(?:if|unless|while|until|for|case|begin|do)\b/;
67
+ const _RB_BLOCK_KW = /\b(?:def|class|module|if|unless|while|until|for|case|begin|do)\b/;
68
+
69
+ function _splitStatements(body) {
70
+ const lines = body.split('\n');
71
+ const out = [];
72
+ let buf = '';
73
+ let depth = 0;
74
+ for (const rawLine of lines) {
75
+ const line = rawLine.trim();
76
+ if (!line || line.startsWith('#')) continue;
77
+ if (depth === 0 && _RB_OPENERS.test(line)) {
78
+ if (buf.trim()) out.push(buf.trim());
79
+ buf = line + '\n';
80
+ for (const m of line.matchAll(/\b(?:if|unless|while|until|for|case|begin|do|def|class|module)\b/g)) depth++;
81
+ if (/\bend\b/.test(line)) depth--;
82
+ if (depth <= 0) { depth = 0; out.push(buf.trim()); buf = ''; }
83
+ continue;
84
+ }
85
+ if (depth > 0) {
86
+ buf += line + '\n';
87
+ for (const m of line.matchAll(/\b(?:if|unless|while|until|for|case|begin|do|def|class|module)\b/g)) depth++;
88
+ const endMatches = line.match(/\bend\b/g);
89
+ if (endMatches) depth -= endMatches.length;
90
+ if (depth <= 0) { depth = 0; out.push(buf.trim()); buf = ''; }
91
+ continue;
92
+ }
93
+ out.push(line);
94
+ }
95
+ if (buf.trim()) out.push(buf.trim());
96
+ return out;
97
+ }
98
+
99
+ function _lowerExpr(text) {
100
+ const s = String(text || '').trim();
101
+ if (!s) return { kind: 'unknown' };
102
+ // String interpolation before plain literal check
103
+ if (/^".*#\{/.test(s)) {
104
+ const parts = [];
105
+ for (const m of s.matchAll(/#\{([^}]+)\}/g)) parts.push(_lowerExpr(m[1]));
106
+ if (parts.length) return { kind: 'tpl', parts };
107
+ }
108
+ if (/^['"]/.test(s)) return { kind: 'literal', value: s };
109
+ if (/^\d/.test(s)) return { kind: 'literal', value: s };
110
+ if (/^(true|false|nil)\b/.test(s)) return { kind: 'literal', value: s };
111
+ // Symbol
112
+ if (/^:\w+/.test(s)) return { kind: 'literal', value: s };
113
+ // Call: obj.method(args) or method(args)
114
+ const callMatch = s.match(/^([\w.]+)\s*\((.*)\)\s*$/s);
115
+ if (callMatch) {
116
+ return { kind: 'call', callee: callMatch[1], args: _splitTopLevelCommas(callMatch[2]).map(_lowerExpr) };
117
+ }
118
+ // Method call without parens is very common in Ruby but hard to detect
119
+ // reliably with regex. We handle the explicit-paren form above.
120
+ // Dotted member: obj.prop
121
+ if (/^[A-Za-z_]\w*(?:\.\w+)+$/.test(s)) {
122
+ const parts = s.split('.');
123
+ let cur = { kind: 'ident', name: parts[0] };
124
+ for (let i = 1; i < parts.length; i++) cur = { kind: 'member', object: cur, prop: parts[i] };
125
+ return cur;
126
+ }
127
+ // Hash access: params[:key]
128
+ if (/^[A-Za-z_]\w*\[/.test(s)) {
129
+ const lb = s.indexOf('[');
130
+ const base = s.slice(0, lb);
131
+ return { kind: 'member', object: { kind: 'ident', name: base }, prop: '[]' };
132
+ }
133
+ // Simple ident
134
+ if (/^[A-Za-z_@]\w*$/.test(s)) return { kind: 'ident', name: s };
135
+ // Concat with +
136
+ if (s.includes('+')) {
137
+ const parts = s.split('+').map(p => _lowerExpr(p.trim()));
138
+ return { kind: 'tpl', parts };
139
+ }
140
+ return { kind: 'unknown' };
141
+ }
142
+
143
+ function _splitTopLevelCommas(s) {
144
+ const out = [];
145
+ let buf = '';
146
+ let depth = 0;
147
+ let inStr = null;
148
+ for (let i = 0; i < s.length; i++) {
149
+ const c = s[i];
150
+ if (inStr) {
151
+ buf += c;
152
+ if (c === '\\') { i++; buf += s[i] || ''; continue; }
153
+ if (c === inStr) inStr = null;
154
+ continue;
155
+ }
156
+ if (c === '"' || c === '\'') { inStr = c; buf += c; continue; }
157
+ if (c === '(' || c === '{' || c === '[') depth++;
158
+ if (c === ')' || c === '}' || c === ']') depth--;
159
+ if (c === ',' && depth === 0) { out.push(buf.trim()); buf = ''; continue; }
160
+ buf += c;
161
+ }
162
+ if (buf.trim()) out.push(buf.trim());
163
+ return out;
164
+ }
165
+
166
+ function _lowerStmt(stmt, line) {
167
+ const s = stmt.trim();
168
+ if (!s || s.startsWith('#')) return null;
169
+ if (/^return\b/.test(s)) {
170
+ const rest = s.replace(/^return\s*/, '').trim();
171
+ return { kind: 'return', line, value: rest ? _lowerExpr(rest) : null };
172
+ }
173
+ if (/^raise\b/.test(s)) {
174
+ return { kind: 'throw', line, value: _lowerExpr(s.replace(/^raise\s*/, '')) };
175
+ }
176
+ // Assignment: var = expr
177
+ const assign = s.match(/^(@?\w+)\s*=\s*(.+)$/s);
178
+ if (assign && !/^={2}/.test(assign[2])) {
179
+ return { kind: 'assign', line, target: assign[1], source: _lowerExpr(assign[2]) };
180
+ }
181
+ // Statement-form call with parens
182
+ const call = s.match(/^([\w.]+)\s*\((.*)\)\s*$/s);
183
+ if (call) {
184
+ return { kind: 'call', line, callee: call[1], args: _splitTopLevelCommas(call[2]).map(_lowerExpr) };
185
+ }
186
+ // Statement-form call without parens (common Ruby idiom): redirect_to expr
187
+ const bareCall = s.match(/^([a-z_]\w*)\s+(.+)$/s);
188
+ if (bareCall && /^[a-z_]/.test(bareCall[1]) && !/^(?:if|unless|while|until|for|case|when|elsif|else|end|return|raise|require|include|extend|attr_\w+)$/.test(bareCall[1])) {
189
+ return { kind: 'call', line, callee: bareCall[1], args: [_lowerExpr(bareCall[2])] };
190
+ }
191
+ return null;
192
+ }
193
+
194
+ function _lineAt(src, idx) {
195
+ let line = 1;
196
+ for (let i = 0; i < idx && i < src.length; i++) if (src[i] === '\n') line++;
197
+ return line;
198
+ }
199
+
200
+ function _qid(file, name, line, body) {
201
+ const sha = crypto.createHash('sha256').update(body).digest('hex').slice(0, 8);
202
+ return `${file}::${name}@${line}#${sha}`;
203
+ }
204
+
205
+ let _nid = 0;
206
+ function _nextId() { return `rn${++_nid}`; }
207
+
208
+ function _addNode(nodes, node) {
209
+ const id = _nextId();
210
+ node.succ = node.succ || [];
211
+ node.pred = node.pred || [];
212
+ nodes[id] = node;
213
+ return id;
214
+ }
215
+
216
+ function _linkNodes(nodes, src, dst) {
217
+ if (!nodes[src] || !nodes[dst]) return;
218
+ if (!nodes[src].succ.includes(dst)) nodes[src].succ.push(dst);
219
+ if (!nodes[dst].pred.includes(src)) nodes[dst].pred.push(src);
220
+ }
221
+
222
+ function _extractRubyBlockBody(compound) {
223
+ const lines = compound.split('\n');
224
+ if (lines.length < 2) return '';
225
+ return lines.slice(1, -1).join('\n');
226
+ }
227
+
228
+ function _buildCfg(bodyText, nodes, prevId, startLine) {
229
+ const stmts = _splitStatements(bodyText);
230
+ let prev = prevId;
231
+ let line = startLine;
232
+ for (const stmt of stmts) {
233
+ const s = stmt.trim();
234
+ if (!s || s.startsWith('#')) { line++; continue; }
235
+
236
+ const ifMatch = s.match(/^(if|unless)\s+(.+)$/m);
237
+ if (ifMatch && /\bend\b\s*$/.test(s)) {
238
+ const condText = ifMatch[2].trim();
239
+ const innerBody = _extractRubyBlockBody(s);
240
+ const ifNode = _addNode(nodes, { kind: 'if', cond: _lowerExpr(condText), line });
241
+ _linkNodes(nodes, prev, ifNode);
242
+ const join = _addNode(nodes, { kind: 'noop', line });
243
+ const thenTail = _buildCfg(innerBody, nodes, ifNode, line + 1);
244
+ _linkNodes(nodes, thenTail, join);
245
+ _linkNodes(nodes, ifNode, join);
246
+ prev = join;
247
+ line += (s.match(/\n/g) || []).length + 1;
248
+ continue;
249
+ }
250
+
251
+ const whileMatch = s.match(/^(while|until)\s+(.+)$/m);
252
+ if (whileMatch && /\bend\b\s*$/.test(s)) {
253
+ const innerBody = _extractRubyBlockBody(s);
254
+ const header = _addNode(nodes, { kind: 'loop-header', line });
255
+ _linkNodes(nodes, prev, header);
256
+ const bodyTail = _buildCfg(innerBody, nodes, header, line + 1);
257
+ _linkNodes(nodes, bodyTail, header);
258
+ const join = _addNode(nodes, { kind: 'noop', line });
259
+ _linkNodes(nodes, header, join);
260
+ prev = join;
261
+ line += (s.match(/\n/g) || []).length + 1;
262
+ continue;
263
+ }
264
+
265
+ const node = _lowerStmt(s, line);
266
+ if (!node) { line += (s.match(/\n/g) || []).length + 1; continue; }
267
+ const id = _addNode(nodes, node);
268
+ _linkNodes(nodes, prev, id);
269
+ prev = id;
270
+ line += (s.match(/\n/g) || []).length + 1;
271
+ }
272
+ return prev;
273
+ }
274
+
275
+ export function parseRubyFile(file, code) {
276
+ if (!file || typeof code !== 'string') return null;
277
+ if (!/\.rb$/i.test(file)) return null;
278
+ if (code.length > 1_000_000) return null;
279
+
280
+ const functions = [];
281
+ DEF_RE.lastIndex = 0;
282
+ _nid = 0;
283
+ let m;
284
+ while ((m = DEF_RE.exec(code)) !== null) {
285
+ const name = m[1];
286
+ const paramsText = m[2] || '';
287
+ const params = paramsText.split(',').map(p => {
288
+ const t = p.trim().replace(/\s*=\s*.*$/, '').replace(/^[*&]+/, '');
289
+ return t && /^\w+$/.test(t) ? t : null;
290
+ }).filter(Boolean);
291
+ const defLineEnd = code.indexOf('\n', m.index + m[0].length);
292
+ if (defLineEnd < 0) continue;
293
+ const extracted = _extractRubyBody(code, defLineEnd + 1);
294
+ if (!extracted) continue;
295
+ const startLine = _lineAt(code, m.index);
296
+ const nodes = {};
297
+ const entry = _addNode(nodes, { kind: 'entry', line: startLine });
298
+ const exit = _addNode(nodes, { kind: 'exit', line: startLine });
299
+ const tail = _buildCfg(extracted.body, nodes, entry, startLine + 1);
300
+ _linkNodes(nodes, tail, exit);
301
+ functions.push({
302
+ qid: _qid(file, name, startLine, extracted.body),
303
+ name, line: startLine, params, file,
304
+ cfg: { entry, exit, nodes },
305
+ });
306
+ DEF_RE.lastIndex = extracted.end;
307
+ }
308
+ return functions.length ? { file, functions, topLevel: null } : null;
309
+ }
@@ -44,6 +44,7 @@
44
44
  import * as fs from 'node:fs';
45
45
  import * as path from 'node:path';
46
46
  import * as crypto from 'node:crypto';
47
+ import { statePath, ensureStateDir, safeWriteState } from '../posture/state-dir.js';
47
48
 
48
49
  // Bump on every prompt change so the cache invalidates. Exported as a
49
50
  // stable public symbol (premortem 4R-15) so the validator-cache GC subcommand
@@ -98,7 +99,9 @@ function endpointConfig() {
98
99
  }
99
100
 
100
101
  function ensureCacheDir(scanRoot) {
101
- const dir = path.join(scanRoot || process.cwd(), CACHE_DIR);
102
+ const base = ensureStateDir(scanRoot);
103
+ if (!base) return null;
104
+ const dir = path.join(base, 'llm-cache');
102
105
  try { fs.mkdirSync(dir, { recursive: true }); } catch {}
103
106
  return dir;
104
107
  }
@@ -112,15 +115,14 @@ function cacheKey(finding, fileHash, modelId) {
112
115
  }
113
116
 
114
117
  function readCache(scanRoot, key) {
115
- const fp = path.join(scanRoot || process.cwd(), CACHE_DIR, key + '.json');
118
+ const fp = statePath(scanRoot, 'llm-cache', key + '.json');
116
119
  if (!fs.existsSync(fp)) return null;
117
120
  try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
118
121
  }
119
122
 
120
123
  function writeCache(scanRoot, key, value) {
121
- ensureCacheDir(scanRoot);
122
- const fp = path.join(scanRoot || process.cwd(), CACHE_DIR, key + '.json');
123
- try { fs.writeFileSync(fp, JSON.stringify(value, null, 2)); } catch {}
124
+ const fp = statePath(scanRoot, 'llm-cache', key + '.json');
125
+ safeWriteState(fp, JSON.stringify(value, null, 2));
124
126
  }
125
127
 
126
128
  function fileHashOf(fileContents, file) {
package/src/mcp/audit.js CHANGED
@@ -82,6 +82,11 @@ async function _postRemote(url, entry) {
82
82
  export function auditCall({ sessionRoot, tool, args, outcome, reason }) {
83
83
  if (!sessionRoot) return;
84
84
  try {
85
+ // Safety: only write audit log if sessionRoot looks like a project root
86
+ const MARKERS = ['.git', 'package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'pom.xml', 'composer.json', 'Gemfile'];
87
+ let hasMarker = false;
88
+ for (const m of MARKERS) { try { if (fs.existsSync(path.join(sessionRoot, m))) { hasMarker = true; break; } } catch {} }
89
+ if (!hasMarker) return;
85
90
  const dir = path.join(sessionRoot, '.agentic-security');
86
91
  fs.mkdirSync(dir, { recursive: true });
87
92
  const logFile = path.join(dir, 'mcp-audit.log');
@@ -28,13 +28,14 @@
28
28
 
29
29
  import * as fs from 'node:fs';
30
30
  import * as path from 'node:path';
31
+ import { statePath } from './state-dir.js';
31
32
 
32
33
  const DEFAULT_THRESHOLD = 0.15;
33
34
  const MIN_SAMPLE_SIZE = 10;
34
35
  const WINDOW_DAYS = 30;
35
36
 
36
37
  function loadTriageFeedback(scanRoot) {
37
- const fp = path.join(scanRoot || process.cwd(), '.agentic-security', 'triage-feedback.json');
38
+ const fp = statePath(scanRoot, 'triage-feedback.json');
38
39
  try {
39
40
  if (!fs.existsSync(fp)) return [];
40
41
  const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
@@ -27,6 +27,7 @@
27
27
 
28
28
  import * as fs from 'node:fs';
29
29
  import * as path from 'node:path';
30
+ import { statePath } from './state-dir.js';
30
31
 
31
32
  const MIN_SAMPLES_FOR_CALIBRATION = 30;
32
33
 
@@ -102,7 +103,7 @@ function _readJsonMaybe(fp) {
102
103
  // seed file. The bundled seed ships with this release; the customer file
103
104
  // overrides per-family when N is higher there.
104
105
  export function loadCalibrationHistory(scanRoot) {
105
- const customer = _readJsonMaybe(path.join(scanRoot || process.cwd(), '.agentic-security', 'validator-metrics.json')) || {};
106
+ const customer = _readJsonMaybe(statePath(scanRoot, 'validator-metrics.json')) || {};
106
107
  const seedPath = new URL('./calibration-seed.json', import.meta.url);
107
108
  let seed = null;
108
109
  try { seed = JSON.parse(fs.readFileSync(seedPath, 'utf8')); } catch { seed = null; }
@@ -120,6 +121,20 @@ export function loadCalibrationHistory(scanRoot) {
120
121
  };
121
122
  if (seed) merge(seed);
122
123
  if (customer) merge(customer);
124
+ // Merge triage-derived TP/FP counts (auto-feedback loop)
125
+ try {
126
+ const triage = _readJsonMaybe(statePath(scanRoot, 'triage.json'));
127
+ if (triage && triage.findings) {
128
+ const triageFams = {};
129
+ for (const f of Object.values(triage.findings)) {
130
+ const fam = f.family || 'unknown';
131
+ if (!triageFams[fam]) triageFams[fam] = { tp: 0, fp: 0 };
132
+ if (f.state === 'fixed' || f.state === 'open' || f.state === 'in-progress') triageFams[fam].tp++;
133
+ else if (f.state === 'false-positive') triageFams[fam].fp++;
134
+ }
135
+ merge({ families: triageFams });
136
+ }
137
+ } catch { /* triage file optional */ }
123
138
  return { families };
124
139
  }
125
140
 
@@ -12,13 +12,19 @@ import * as fs from 'node:fs';
12
12
  import * as fsp from 'node:fs/promises';
13
13
  import * as path from 'node:path';
14
14
  import * as crypto from 'node:crypto';
15
+ import { isSafeStateDir, statePath } from './state-dir.js';
15
16
 
16
17
  function historyDir(scanRoot) {
17
- return path.join(scanRoot, '.agentic-security', 'fix-history');
18
+ return statePath(scanRoot, 'fix-history');
18
19
  }
19
20
  function logPath(scanRoot) { return path.join(historyDir(scanRoot), 'log.json'); }
20
21
 
21
- function ensure(scanRoot) { fs.mkdirSync(historyDir(scanRoot), { recursive: true }); }
22
+ function ensure(scanRoot) {
23
+ const dir = historyDir(scanRoot);
24
+ if (!isSafeStateDir(path.dirname(dir))) return false;
25
+ fs.mkdirSync(dir, { recursive: true });
26
+ return true;
27
+ }
22
28
 
23
29
  export function readLog(scanRoot) {
24
30
  const fp = logPath(scanRoot);
@@ -6,6 +6,7 @@
6
6
  import * as fs from 'node:fs';
7
7
  import * as path from 'node:path';
8
8
  import * as yaml from 'js-yaml';
9
+ import { statePath, safeWriteState, resolveProjectRoot } from './state-dir.js';
9
10
 
10
11
  export const PROFILES = ['vibecoder', 'pro'];
11
12
 
@@ -35,7 +36,7 @@ export const DEFAULTS = {
35
36
  };
36
37
 
37
38
  function _profilePath(scanRoot) {
38
- return path.join(scanRoot || process.cwd(), '.agentic-security', 'profile.yml');
39
+ return statePath(scanRoot, 'profile.yml');
39
40
  }
40
41
 
41
42
  export function loadProfile(scanRoot) {
@@ -52,10 +53,8 @@ export function loadProfile(scanRoot) {
52
53
 
53
54
  export function saveProfile(scanRoot, updates) {
54
55
  const fp = _profilePath(scanRoot);
55
- fs.mkdirSync(path.dirname(fp), { recursive: true });
56
56
  const current = loadProfile(scanRoot);
57
57
  const next = { ...current, ...updates };
58
- // Strip values equal to defaults so the file stays minimal.
59
58
  const defaults = DEFAULTS[next.profile];
60
59
  const out = {};
61
60
  for (const k of Object.keys(next)) {
@@ -63,7 +62,7 @@ export function saveProfile(scanRoot, updates) {
63
62
  out[k] = next[k];
64
63
  }
65
64
  if (!('profile' in out)) out.profile = next.profile;
66
- fs.writeFileSync(fp, yaml.dump(out));
65
+ safeWriteState(fp, yaml.dump(out));
67
66
  return next;
68
67
  }
69
68
 
@@ -71,7 +70,7 @@ export function saveProfile(scanRoot, updates) {
71
70
  // Returns 'pro' if the repo has signals indicating professional security work,
72
71
  // otherwise 'vibecoder'. Run only on first scan.
73
72
  export function detectProfile(scanRoot) {
74
- const root = scanRoot || process.cwd();
73
+ const root = resolveProjectRoot(scanRoot);
75
74
  const signals = ['SECURITY.md', '.github/workflows/security.yml', '.semgrep.yml',
76
75
  '.snyk', 'codeql-config.yml', 'compliance/', 'docs/threat-model.md'];
77
76
  for (const s of signals) {
@@ -11,11 +11,10 @@ import * as fs from 'node:fs';
11
11
  import * as path from 'node:path';
12
12
  import * as yaml from 'js-yaml';
13
13
  import { verifyLastScan } from './integrity.js';
14
-
15
- const OVERRIDES_PATH = '.agentic-security/rules.yml';
14
+ import { statePath } from './state-dir.js';
16
15
 
17
16
  function _path(scanRoot) {
18
- return path.join(scanRoot || process.cwd(), OVERRIDES_PATH);
17
+ return statePath(scanRoot, 'rules.yml');
19
18
  }
20
19
 
21
20
  export function loadOverrides(scanRoot) {
@@ -30,8 +30,7 @@
30
30
  import * as fs from 'node:fs';
31
31
  import * as path from 'node:path';
32
32
  import * as crypto from 'node:crypto';
33
-
34
- const TRUSTED_KEYS_FILE = '.agentic-security/trusted-keys.json';
33
+ import { statePath } from './state-dir.js';
35
34
 
36
35
  // Built-in trust root. These are the keys the maintainers of agentic-security
37
36
  // use to sign official rule packs. Production deployment requires the
@@ -49,7 +48,7 @@ export const BUNDLED_OFFICIAL_KEYS = [
49
48
  ];
50
49
 
51
50
  function _trustedKeysPath(scanRoot) {
52
- return path.join(scanRoot || process.cwd(), TRUSTED_KEYS_FILE);
51
+ return statePath(scanRoot, 'trusted-keys.json');
53
52
  }
54
53
 
55
54
  // Load the EFFECTIVE trusted-key set. Composition:
@@ -13,14 +13,12 @@
13
13
 
14
14
  import * as fs from 'node:fs';
15
15
  import * as path from 'node:path';
16
-
17
- const TRIAGE_PATH = path.join('.agentic-security', 'triage-feedback.json');
18
- const PROPOSED_DIR = path.join('.agentic-security', 'rules-proposed');
16
+ import { statePath, isSafeStateDir } from './state-dir.js';
19
17
 
20
18
  const DEFAULT_FP_THRESHOLD = 5;
21
19
 
22
20
  function _readTriage(scanRoot) {
23
- const fp = path.join(scanRoot || process.cwd(), TRIAGE_PATH);
21
+ const fp = statePath(scanRoot, 'triage-feedback.json');
24
22
  if (!fs.existsSync(fp)) return null;
25
23
  try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
26
24
  }
@@ -87,7 +85,8 @@ export function synthesizeRules(scanRoot, opts = {}) {
87
85
  groups.get(k).push(e);
88
86
  }
89
87
  const proposals = [];
90
- const dir = path.join(scanRoot || process.cwd(), PROPOSED_DIR);
88
+ const dir = statePath(scanRoot, 'rules-proposed');
89
+ if (!opts.dryRun && !isSafeStateDir(path.dirname(dir))) return [];
91
90
  for (const [, group] of groups) {
92
91
  if (group.length < threshold) continue;
93
92
  const summary = _summarizeGroup(group);
@@ -105,4 +104,4 @@ export function synthesizeRules(scanRoot, opts = {}) {
105
104
  return proposals;
106
105
  }
107
106
 
108
- export const _internals = { DEFAULT_FP_THRESHOLD, TRIAGE_PATH, PROPOSED_DIR };
107
+ export const _internals = { DEFAULT_FP_THRESHOLD };
@@ -6,12 +6,12 @@
6
6
 
7
7
  import * as fs from 'node:fs';
8
8
  import * as path from 'node:path';
9
+ import { statePath, safeWriteState } from './state-dir.js';
9
10
 
10
- const HISTORY_FILE = '.agentic-security/scan-history.json';
11
11
  const MAX_HISTORY = 30; // rolling window
12
12
 
13
13
  function _readHistory(scanRoot) {
14
- const histPath = scanRoot ? path.join(scanRoot, HISTORY_FILE) : HISTORY_FILE;
14
+ const histPath = statePath(scanRoot, 'scan-history.json');
15
15
  try {
16
16
  return JSON.parse(fs.readFileSync(histPath, 'utf8'));
17
17
  } catch {
@@ -20,11 +20,8 @@ function _readHistory(scanRoot) {
20
20
  }
21
21
 
22
22
  function _writeHistory(scanRoot, history) {
23
- const histPath = scanRoot ? path.join(scanRoot, HISTORY_FILE) : HISTORY_FILE;
24
- try {
25
- fs.mkdirSync(path.dirname(histPath), { recursive: true });
26
- fs.writeFileSync(histPath, JSON.stringify(history.slice(-MAX_HISTORY), null, 2));
27
- } catch {}
23
+ const histPath = statePath(scanRoot, 'scan-history.json');
24
+ safeWriteState(histPath, JSON.stringify(history.slice(-MAX_HISTORY), null, 2));
28
25
  }
29
26
 
30
27
  function _snapshotFromScan(scan, label) {