brainblast 0.3.0 → 0.4.1

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/README.md CHANGED
@@ -11,10 +11,52 @@ parses your code statically and runs offline.
11
11
  npx brainblast . # scan the repo, write .agent-research/report.json
12
12
  npx brainblast . --ci # exit 1 if a confirmed FAIL remains
13
13
  npx brainblast . --ci --strict # also fail on CANT_TELL (can't statically prove)
14
+ npx brainblast . --since origin/main # diff-aware: only audit what changed
14
15
  ```
15
16
 
16
17
  Exit codes: **0** clean · **1** a confirmed FAIL · CANT_TELL is a warning by
17
- default (a red build always means a real, confirmed problem).
18
+ default (a red build always means a real, confirmed problem). `2` means
19
+ `--since <ref>` could not run `git diff` (bad ref, or not a git work tree).
20
+
21
+ ### Diff-aware scanning (`--since <ref>`)
22
+
23
+ `--since <ref>` audits only what's changed relative to `<ref>` (any git
24
+ revision: a branch, `HEAD~1`, a commit SHA): TS/Rust functions whose line
25
+ range overlaps `git diff <ref>`, and config/env files that changed at all.
26
+ This makes brainblast fast enough to run on every commit or PR instead of a
27
+ full-repo scan:
28
+
29
+ ```sh
30
+ npx brainblast . --since origin/main # CI: only the PR's diff
31
+ npx brainblast . --since HEAD # pre-commit/save hook: working tree changes
32
+ ```
33
+
34
+ Living-memory precedents (see below) are still looked up and shown in
35
+ `--since` mode, but the memory snapshot itself is only written on full
36
+ (non-`--since`) runs — a partial diff-scan never overwrites the full picture.
37
+
38
+ ### Watch mode (`brainblast watch`)
39
+
40
+ ```sh
41
+ npx brainblast watch .
42
+ ```
43
+
44
+ Runs as a daemon: every time a file is saved, brainblast re-scans only the
45
+ working-tree changes (uncommitted edits vs `HEAD`, plus untracked files —
46
+ the "what did I just save?" view) and emits one **NDJSON event per line** on
47
+ stdout:
48
+
49
+ ```json
50
+ {"type":"watch_started","targetDir":"."}
51
+ {"type":"finding","ruleId":"stripe-webhook-raw-body-verification","severity":"critical","result":"fail","file":"src/webhook.ts","line":3,"detail":"...","fix":{...}}
52
+ {"type":"scan_complete","filesChanged":1,"findings":1,"durationMs":62}
53
+ ```
54
+
55
+ Event types: `watch_started`, `finding` (one per FAIL/CANT_TELL), `scan_complete`
56
+ (per debounced save, even if nothing changed), and `scan_error` (e.g. not a
57
+ git work tree). This is the integration point for an agent daemon — tail
58
+ stdout for structured findings instead of polling `.agent-research/report.json`.
59
+ Exit with Ctrl-C / SIGTERM.
18
60
 
19
61
  ## What it catches
20
62
 
@@ -34,6 +76,12 @@ default (a red build always means a real, confirmed problem).
34
76
  | `metaplex-metadata-immutable` | `createV1` / `createNft` omits `isMutable: false` | Metadata defaults to mutable; any update authority can change the token's name, image, or attributes after launch |
35
77
  | `anchor-init-if-needed-guarded` | Anchor instruction uses `init_if_needed` without a re-initialization guard | Any user can reinitialize another user's account, overwriting its state |
36
78
 
79
+ ### Config / env
80
+
81
+ | Rule | What's wrong | Consequence |
82
+ |------|--------------|-------------|
83
+ | `env-secrets-committed` | A `.env*` file (not `.env.example`/`.sample`/`.template`) is tracked by git and contains a secret-shaped key (`SECRET`, `*_PRIVATE_KEY`, `*_API_KEY`, `*_TOKEN`, `*_PASSWORD`, etc.) with a real-looking (non-placeholder) value | Anyone with read access to the repo — including forks of a public repo — can read the live credential |
84
+
37
85
  Each finding lands in `.agent-research/report.json` (stable `schemaVersion: "1.0"`)
38
86
  with a `checks[]` array a CI gate can read. Each confirmed FAIL ships a
39
87
  generated behavioral test (RED on vulnerable, GREEN on fixed).
@@ -116,7 +164,7 @@ All types are exported: `Rule`, `CheckResult`, `CostReport`, `AccountFlow`,
116
164
 
117
165
  ```sh
118
166
  npm install
119
- npm test # unit suite (135 tests)
167
+ npm test # unit suite (164 tests)
120
168
  npm run prove # end-to-end: generated tests RED on vulnerable, GREEN on fixed
121
169
  npm run build # produce dist/ (the published artifact)
122
170
  ```
@@ -4,9 +4,10 @@ import { Project, SyntaxKind } from "ts-morph";
4
4
  // src/walk.ts
5
5
  import { readdirSync, statSync } from "fs";
6
6
  import { join } from "path";
7
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".gen", "dist", ".next", ".agent-research"]);
7
8
  function walk(dir, out = []) {
8
9
  for (const entry of readdirSync(dir)) {
9
- if (entry === "node_modules" || entry === ".git" || entry === ".gen") continue;
10
+ if (SKIP_DIRS.has(entry)) continue;
10
11
  const p = join(dir, entry);
11
12
  const st = statSync(p);
12
13
  if (st.isDirectory()) walk(p, out);
@@ -14,6 +15,16 @@ function walk(dir, out = []) {
14
15
  }
15
16
  return out;
16
17
  }
18
+ function walkAllFiles(dir, out = []) {
19
+ for (const entry of readdirSync(dir)) {
20
+ if (SKIP_DIRS.has(entry)) continue;
21
+ const p = join(dir, entry);
22
+ const st = statSync(p);
23
+ if (st.isDirectory()) walkAllFiles(p, out);
24
+ else out.push(p);
25
+ }
26
+ return out;
27
+ }
17
28
 
18
29
  // src/finder.ts
19
30
  function bodyCallsAnyOf(fn, names) {
@@ -38,7 +49,13 @@ function findCandidates(targetDir, rule) {
38
49
  const sf = project.addSourceFileAtPath(file);
39
50
  const importsModule = sf.getImportDeclarations().some((d) => modules.has(d.getModuleSpecifierValue()));
40
51
  const consider = (fn, name) => {
41
- if (!(importsModule || name && nameRe.test(name) || bodyCallsAnyOf(fn, triggers))) return;
52
+ const hasName = !!(name && nameRe.test(name));
53
+ const hasTrigger = bodyCallsAnyOf(fn, triggers);
54
+ if (rule.detect.requiresImport) {
55
+ if (!(importsModule && (hasName || hasTrigger))) return;
56
+ } else {
57
+ if (!(hasName || hasTrigger)) return;
58
+ }
42
59
  out.push({
43
60
  filePath: file,
44
61
  fnName: name || "(anonymous)",
@@ -55,6 +72,42 @@ function findCandidates(targetDir, rule) {
55
72
  return out;
56
73
  }
57
74
 
75
+ // src/configFinder.ts
76
+ import { execFileSync } from "child_process";
77
+ import { readFileSync } from "fs";
78
+ import { relative, sep } from "path";
79
+ function isGitIgnored(targetDir, rel) {
80
+ try {
81
+ execFileSync("git", ["check-ignore", "-q", "--", rel], { cwd: targetDir, stdio: "ignore" });
82
+ return true;
83
+ } catch (e) {
84
+ if (typeof e?.status === "number") return false;
85
+ return false;
86
+ }
87
+ }
88
+ function findConfigCandidates(targetDir, rule) {
89
+ const patterns = (rule.detect.filePatterns ?? []).map((p) => new RegExp(p));
90
+ if (patterns.length === 0) return [];
91
+ const files = walkAllFiles(targetDir);
92
+ const out = [];
93
+ for (const file of files) {
94
+ const rel = relative(targetDir, file).split(sep).join("/");
95
+ if (!patterns.some((re) => re.test(rel))) continue;
96
+ let content;
97
+ try {
98
+ content = readFileSync(file, "utf8");
99
+ } catch {
100
+ continue;
101
+ }
102
+ out.push({
103
+ filePath: file,
104
+ content,
105
+ tracked: !isGitIgnored(targetDir, rel)
106
+ });
107
+ }
108
+ return out;
109
+ }
110
+
58
111
  // src/checkers/positionalArgIdentity.ts
59
112
  import { SyntaxKind as SyntaxKind2 } from "ts-morph";
60
113
  var positionalArgIdentity = (c, p) => {
@@ -62,7 +115,20 @@ var positionalArgIdentity = (c, p) => {
62
115
  const exp = call.getExpression();
63
116
  return exp.getKind() === SyntaxKind2.PropertyAccessExpression && exp.asKind(SyntaxKind2.PropertyAccessExpression).getName() === p.call;
64
117
  });
65
- if (calls.length === 0) return { result: "fail", detail: p.absentDetail };
118
+ if (calls.length === 0) {
119
+ const sf = c.fn.getSourceFile();
120
+ const existsInFile = sf.getDescendantsOfKind(SyntaxKind2.CallExpression).some((call) => {
121
+ const exp = call.getExpression();
122
+ return exp.getKind() === SyntaxKind2.PropertyAccessExpression && exp.asKind(SyntaxKind2.PropertyAccessExpression).getName() === p.call;
123
+ });
124
+ if (existsInFile) {
125
+ return {
126
+ result: "cant_tell",
127
+ detail: `${p.call} is called elsewhere in this file; unable to confirm this function's delegation path statically.`
128
+ };
129
+ }
130
+ return { result: "fail", detail: p.absentDetail };
131
+ }
66
132
  const arg = calls[0].getArguments()[p.argIndex];
67
133
  const wantParam = c.params[p.paramIndex];
68
134
  if (arg && wantParam && arg.getKind() === SyntaxKind2.Identifier && arg.getText() === wantParam) {
@@ -391,6 +457,36 @@ function anchorInitIfNeededGuarded(c, p) {
391
457
  };
392
458
  }
393
459
 
460
+ // src/checkers/envSecretsCommitted.ts
461
+ var envSecretsCommitted = (c, p) => {
462
+ if (!c.tracked) {
463
+ return {
464
+ result: "pass",
465
+ detail: p.ignoredDetail ?? "File is git-ignored; not committed to source control."
466
+ };
467
+ }
468
+ const keyRe = new RegExp(p.secretKeyPattern, "i");
469
+ const placeholderRe = new RegExp(p.placeholderPattern, "i");
470
+ const offenders = [];
471
+ for (const rawLine of c.content.split("\n")) {
472
+ const line = rawLine.trim();
473
+ if (!line || line.startsWith("#")) continue;
474
+ const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
475
+ if (!m) continue;
476
+ const [, key, rawValue] = m;
477
+ if (!keyRe.test(key)) continue;
478
+ const value = (rawValue ?? "").trim().replace(/^["']|["']$/g, "");
479
+ if (!value) continue;
480
+ if (placeholderRe.test(value)) continue;
481
+ offenders.push(key);
482
+ }
483
+ if (offenders.length > 0) {
484
+ const prefix = p.failDetailPrefix ?? "This file is tracked by git and contains secret-looking values";
485
+ return { result: "fail", detail: `${prefix}: ${offenders.join(", ")}.` };
486
+ }
487
+ return { result: "pass", detail: p.passDetail ?? "No committed secret-looking values found." };
488
+ };
489
+
394
490
  // src/checkers/index.ts
395
491
  var registry = {
396
492
  "positional-arg-identity": positionalArgIdentity,
@@ -398,7 +494,8 @@ var registry = {
398
494
  "fee-allocation-shape": feeAllocationShape,
399
495
  "arg-equals-constant-identifier": argEqualsConstantIdentifier,
400
496
  "object-arg-property-literal-equals": objectArgPropertyLiteralEquals,
401
- "anchor-init-if-needed-guarded": anchorInitIfNeededGuarded
497
+ "anchor-init-if-needed-guarded": anchorInitIfNeededGuarded,
498
+ "env-secrets-committed": envSecretsCommitted
402
499
  };
403
500
  function runChecker(kind, c, params) {
404
501
  const fn = registry[kind];
@@ -407,14 +504,89 @@ function runChecker(kind, c, params) {
407
504
  }
408
505
  var checkerKinds = Object.keys(registry);
409
506
 
410
- // src/rustFinder.ts
411
- import { readFileSync, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
507
+ // src/gitDiff.ts
508
+ import { execFileSync as execFileSync2 } from "child_process";
509
+ import { readFileSync as readFileSync2 } from "fs";
412
510
  import { join as join2 } from "path";
511
+ function getChangedRanges(targetDir, ref) {
512
+ let out;
513
+ try {
514
+ out = execFileSync2(
515
+ "git",
516
+ ["diff", "--unified=0", "--no-color", "--no-renames", "--diff-filter=ACMR", "--relative", ref, "--"],
517
+ { cwd: targetDir, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }
518
+ );
519
+ } catch (e) {
520
+ const stderr = e?.stderr?.toString?.() ?? e?.message ?? String(e);
521
+ throw new Error(`brainblast: 'git diff ${ref}' failed: ${stderr.trim()}`);
522
+ }
523
+ const ranges = /* @__PURE__ */ new Map();
524
+ let currentFile = null;
525
+ for (const line of out.split("\n")) {
526
+ if (line.startsWith("+++ ")) {
527
+ const raw = line.slice(4).trim();
528
+ if (raw === "/dev/null") {
529
+ currentFile = null;
530
+ continue;
531
+ }
532
+ const rel = raw.startsWith("b/") ? raw.slice(2) : raw;
533
+ currentFile = join2(targetDir, rel);
534
+ continue;
535
+ }
536
+ if (line.startsWith("@@") && currentFile) {
537
+ const m = line.match(/\+(\d+)(?:,(\d+))?/);
538
+ if (!m) continue;
539
+ const start = parseInt(m[1], 10);
540
+ const count = m[2] !== void 0 ? parseInt(m[2], 10) : 1;
541
+ if (count === 0) continue;
542
+ const end = start + count - 1;
543
+ const arr = ranges.get(currentFile) ?? [];
544
+ arr.push([start, end]);
545
+ ranges.set(currentFile, arr);
546
+ }
547
+ }
548
+ return ranges;
549
+ }
550
+ function getWorkingTreeChanges(targetDir) {
551
+ const ranges = getChangedRanges(targetDir, "HEAD");
552
+ let untracked;
553
+ try {
554
+ untracked = execFileSync2("git", ["ls-files", "--others", "--exclude-standard", "--", "."], {
555
+ cwd: targetDir,
556
+ encoding: "utf8"
557
+ });
558
+ } catch {
559
+ return ranges;
560
+ }
561
+ for (const rel of untracked.split("\n").map((s) => s.trim()).filter(Boolean)) {
562
+ const abs = join2(targetDir, rel);
563
+ let lineCount = 1;
564
+ try {
565
+ lineCount = readFileSync2(abs, "utf8").split("\n").length;
566
+ } catch {
567
+ continue;
568
+ }
569
+ ranges.set(abs, [[1, lineCount]]);
570
+ }
571
+ return ranges;
572
+ }
573
+ function fileChanged(ranges, file) {
574
+ return ranges.has(file);
575
+ }
576
+ function rangeChanged(ranges, file, startLine, endLine) {
577
+ const fileRanges = ranges.get(file);
578
+ if (!fileRanges) return false;
579
+ return fileRanges.some(([s, e]) => startLine <= e && endLine >= s);
580
+ }
581
+
582
+ // src/rustFinder.ts
583
+ import { readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
584
+ import { join as join3 } from "path";
413
585
  import { createRequire } from "module";
414
586
  function walkRust(dir, out = []) {
415
587
  for (const entry of readdirSync2(dir)) {
416
588
  if (entry === "node_modules" || entry === ".git" || entry === "target") continue;
417
- const p = join2(dir, entry);
589
+ const p = join3(dir, entry);
418
590
  const st = statSync2(p);
419
591
  if (st.isDirectory()) walkRust(p, out);
420
592
  else if (p.endsWith(".rs")) out.push(p);
@@ -494,7 +666,7 @@ function findRustCandidates(targetDir, rule) {
494
666
  const out = [];
495
667
  for (const file of walkRust(targetDir)) {
496
668
  if (!file.endsWith(".rs")) continue;
497
- const src = readFileSync(file, "utf8");
669
+ const src = readFileSync3(file, "utf8");
498
670
  const tree = parser.parse(src);
499
671
  const structMap = /* @__PURE__ */ new Map();
500
672
  const topPairs = itemsWithAttrs(tree.rootNode);
@@ -538,6 +710,136 @@ function findRustCandidates(targetDir, rule) {
538
710
  return out;
539
711
  }
540
712
 
713
+ // src/fixers/positionalArgIdentity.ts
714
+ import { SyntaxKind as SyntaxKind7 } from "ts-morph";
715
+
716
+ // src/fixers/diffUtil.ts
717
+ function buildDiff(node, replacement) {
718
+ const sf = node.getSourceFile();
719
+ const filePath = sf.getFilePath();
720
+ const fullText = sf.getFullText();
721
+ const start = node.getStart();
722
+ const end = node.getEnd();
723
+ const startPos = sf.getLineAndColumnAtPos(start);
724
+ const endPos = sf.getLineAndColumnAtPos(end);
725
+ const lines = fullText.split("\n");
726
+ const oldMiddle = lines.slice(startPos.line - 1, endPos.line);
727
+ const oldFirst = oldMiddle[0].slice(0, startPos.column - 1);
728
+ const oldLast = oldMiddle[oldMiddle.length - 1].slice(endPos.column - 1);
729
+ const newMiddle = (oldFirst + replacement + oldLast).split("\n");
730
+ const removed = oldMiddle.map((l) => `-${l}`);
731
+ const added = newMiddle.map((l) => `+${l}`);
732
+ const hunkHeader = `@@ -${startPos.line},${oldMiddle.length} +${startPos.line},${newMiddle.length} @@`;
733
+ return [`--- a${filePath}`, `+++ b${filePath}`, hunkHeader, ...removed, ...added].join("\n");
734
+ }
735
+
736
+ // src/fixers/positionalArgIdentity.ts
737
+ var fixPositionalArgIdentity = (c, p, outcome) => {
738
+ if (outcome.result !== "fail") return void 0;
739
+ const calls = c.fn.getDescendantsOfKind(SyntaxKind7.CallExpression).filter((call) => {
740
+ const exp = call.getExpression();
741
+ return exp.getKind() === SyntaxKind7.PropertyAccessExpression && exp.asKind(SyntaxKind7.PropertyAccessExpression).getName() === p.call;
742
+ });
743
+ if (calls.length === 0) {
744
+ const wantParam2 = c.params[p.paramIndex] ?? "<rawBodyParam>";
745
+ return {
746
+ summary: `Add a ${p.call} call that verifies the raw request body`,
747
+ suggestion: `No '${p.call}' call was found in this handler. Verify the signature against the raw, unparsed request body \u2014 parameter '${wantParam2}' \u2014 before trusting the event, e.g.:
748
+
749
+ const event = stripe.webhooks.constructEvent(${wantParam2}, signature, process.env.STRIPE_WEBHOOK_SECRET!);
750
+
751
+ Do not call JSON.parse() on the body before this verification step.`
752
+ };
753
+ }
754
+ const arg = calls[0].getArguments()[p.argIndex];
755
+ const wantParam = c.params[p.paramIndex];
756
+ if (arg && wantParam && arg.getKind() === SyntaxKind7.CallExpression) {
757
+ return {
758
+ summary: `Pass the raw body parameter '${wantParam}' to ${p.call} instead of a parsed value`,
759
+ diff: buildDiff(arg, wantParam)
760
+ };
761
+ }
762
+ return void 0;
763
+ };
764
+
765
+ // src/fixers/requiredCallWithOptions.ts
766
+ import { SyntaxKind as SyntaxKind8 } from "ts-morph";
767
+ function callName4(call) {
768
+ const exp = call.getExpression();
769
+ if (exp.getKind() === SyntaxKind8.Identifier) return exp.getText();
770
+ if (exp.getKind() === SyntaxKind8.PropertyAccessExpression) {
771
+ return exp.asKind(SyntaxKind8.PropertyAccessExpression).getName();
772
+ }
773
+ return "";
774
+ }
775
+ function placeholderFor(propName) {
776
+ switch (propName) {
777
+ case "audience":
778
+ case "aud":
779
+ return `audience: process.env.PRIVY_APP_ID`;
780
+ case "issuer":
781
+ case "iss":
782
+ return `issuer: "https://privy.io"`;
783
+ default:
784
+ return `${propName}: undefined /* TODO: brainblast could not infer this value */`;
785
+ }
786
+ }
787
+ var fixRequiredCallWithOptions = (c, p, outcome) => {
788
+ if (outcome.result !== "fail") return void 0;
789
+ const calls = c.fn.getDescendantsOfKind(SyntaxKind8.CallExpression);
790
+ const verify = calls.filter((x) => p.verifyCalls.includes(callName4(x)));
791
+ if (verify.length > 0) {
792
+ const call = verify[0];
793
+ const args = call.getArguments();
794
+ const lastArg = args[args.length - 1];
795
+ const obj = lastArg?.asKind(SyntaxKind8.ObjectLiteralExpression);
796
+ const presentNames = obj ? obj.getProperties().map((pr) => {
797
+ const pa = pr.asKind(SyntaxKind8.PropertyAssignment) ?? pr.asKind(SyntaxKind8.ShorthandPropertyAssignment);
798
+ return pa?.getName() ?? "";
799
+ }) : [];
800
+ const missingGroups = p.requiredProps.filter(
801
+ (g) => !g.some((n) => presentNames.includes(n))
802
+ );
803
+ if (missingGroups.length === 0) return void 0;
804
+ const newProps = missingGroups.map((g) => placeholderFor(g[0])).join(", ");
805
+ const summary = `Add ${missingGroups.map((g) => g[0]).join(" and ")} to the ${callName4(call)} call`;
806
+ if (obj) {
807
+ const inner = obj.getText().slice(1, -1).trim();
808
+ const newText = inner.length > 0 ? `{ ${inner}, ${newProps} }` : `{ ${newProps} }`;
809
+ return { summary, diff: buildDiff(obj, newText) };
810
+ }
811
+ if (lastArg) {
812
+ const newText = `${lastArg.getText()}, { ${newProps} }`;
813
+ return { summary, diff: buildDiff(lastArg, newText) };
814
+ }
815
+ return {
816
+ summary,
817
+ suggestion: `Add an options object ({ ${newProps} }) as an argument to ${callName4(call)}.`
818
+ };
819
+ }
820
+ return {
821
+ summary: "Replace the decode-only call with a verified call",
822
+ suggestion: `This token is decoded without verifying its signature, accepting any forged token. Replace the decode call with a verifying call that asserts audience and issuer, e.g.:
823
+
824
+ const { payload } = await jwtVerify(token, JWKS, { audience: process.env.PRIVY_APP_ID, issuer: "https://privy.io" });
825
+
826
+ JWKS must come from Privy's published JWKS endpoint for your app.`
827
+ };
828
+ };
829
+
830
+ // src/fixers/index.ts
831
+ var registry2 = {
832
+ "positional-arg-identity": fixPositionalArgIdentity,
833
+ "required-call-with-options": fixRequiredCallWithOptions
834
+ };
835
+ function runFixer(kind, c, params, outcome) {
836
+ if (outcome.result !== "fail") return void 0;
837
+ const fn = registry2[kind];
838
+ if (!fn) return void 0;
839
+ return fn(c, params, outcome);
840
+ }
841
+ var fixerKinds = Object.keys(registry2);
842
+
541
843
  // src/emit.ts
542
844
  function buildReport(target, checks, rules2, costReport) {
543
845
  const byId = new Map(rules2.map((r) => [r.id, r]));
@@ -584,7 +886,9 @@ function buildReport(target, checks, rules2, costReport) {
584
886
  file: c.file,
585
887
  line: c.line,
586
888
  title: c.title,
587
- detail: c.detail
889
+ detail: c.detail,
890
+ ...c.fix ? { fix: c.fix } : {},
891
+ ...c.precedent ? { precedent: c.precedent } : {}
588
892
  })),
589
893
  checkTotals,
590
894
  openQuestions: [],
@@ -593,9 +897,28 @@ function buildReport(target, checks, rules2, costReport) {
593
897
  }
594
898
 
595
899
  // src/audit.ts
596
- function auditWithRule(targetDir, rule) {
900
+ function auditWithRule(targetDir, rule, changedRanges) {
901
+ if (rule.detect.lang === "config") {
902
+ return findConfigCandidates(targetDir, rule).filter((c) => !changedRanges || fileChanged(changedRanges, c.filePath)).map((c) => {
903
+ const outcome = runChecker(rule.check.kind, c, rule.check.params);
904
+ return {
905
+ ruleId: rule.id,
906
+ severity: rule.severity,
907
+ title: rule.title,
908
+ file: c.filePath,
909
+ line: 1,
910
+ exportName: c.filePath,
911
+ ...outcome
912
+ };
913
+ });
914
+ }
597
915
  if (rule.detect.lang === "rust") {
598
- return findRustCandidates(targetDir, rule).map((c) => {
916
+ return findRustCandidates(targetDir, rule).filter((c) => {
917
+ if (!changedRanges) return true;
918
+ const start = (c.fnBodyNode?.startPosition?.row ?? 0) + 1;
919
+ const end = (c.fnBodyNode?.endPosition?.row ?? start - 1) + 1;
920
+ return rangeChanged(changedRanges, c.filePath, start, end);
921
+ }).map((c) => {
599
922
  const outcome = runChecker(rule.check.kind, c, rule.check.params);
600
923
  return {
601
924
  ruleId: rule.id,
@@ -609,8 +932,12 @@ function auditWithRule(targetDir, rule) {
609
932
  };
610
933
  });
611
934
  }
612
- return findCandidates(targetDir, rule).map((c) => {
935
+ return findCandidates(targetDir, rule).filter((c) => {
936
+ if (!changedRanges) return true;
937
+ return rangeChanged(changedRanges, c.filePath, c.fn.getStartLineNumber(), c.fn.getEndLineNumber());
938
+ }).map((c) => {
613
939
  const outcome = runChecker(rule.check.kind, c, rule.check.params);
940
+ const fix = runFixer(rule.check.kind, c, rule.check.params, outcome);
614
941
  return {
615
942
  ruleId: rule.id,
616
943
  severity: rule.severity,
@@ -618,12 +945,13 @@ function auditWithRule(targetDir, rule) {
618
945
  file: c.filePath,
619
946
  line: c.fn.getStartLineNumber(),
620
947
  exportName: c.fnName,
621
- ...outcome
948
+ ...outcome,
949
+ ...fix ? { fix } : {}
622
950
  };
623
951
  });
624
952
  }
625
- function audit(targetDir, rules2) {
626
- const checks = rules2.flatMap((r) => auditWithRule(targetDir, r));
953
+ function audit(targetDir, rules2, changedRanges) {
954
+ const checks = rules2.flatMap((r) => auditWithRule(targetDir, r, changedRanges));
627
955
  const report = buildReport(targetDir, checks, rules2);
628
956
  return { checks, report };
629
957
  }
@@ -902,29 +1230,35 @@ mod brainblast_reinit_guard_test {
902
1230
  }
903
1231
  `;
904
1232
 
1233
+ // src/testTemplates/none.ts
1234
+ var none = (opts) => `// No behavioral contract test applies to this rule.
1235
+ // Finding: ${opts.handlerExport} (${opts.handlerImportPath})
1236
+ `;
1237
+
905
1238
  // src/testTemplates/index.ts
906
- var registry2 = {
1239
+ var registry3 = {
907
1240
  "stripe-webhook-signature": stripeWebhookSignature,
908
1241
  "privy-jwt-claims": privyJwtClaims,
909
1242
  "bags-fee-share": bagsFeeShare,
910
1243
  "token-program-consistency": tokenProgramConsistency,
911
1244
  "metaplex-immutable-metadata": metaplexImmutableMetadata,
912
- "anchor-program-test": anchorProgramTest
1245
+ "anchor-program-test": anchorProgramTest,
1246
+ none
913
1247
  };
914
1248
  var JS_IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
915
1249
  function renderTest(kind, opts) {
916
- const tpl = registry2[kind];
1250
+ const tpl = registry3[kind];
917
1251
  if (!tpl) throw new Error(`Unknown test template kind '${kind}'.`);
918
1252
  if (!JS_IDENTIFIER.test(opts.handlerExport)) {
919
1253
  throw new Error(`Unsafe handler export name '${opts.handlerExport}' (not a JS identifier).`);
920
1254
  }
921
1255
  return tpl(opts);
922
1256
  }
923
- var testKinds = Object.keys(registry2);
1257
+ var testKinds = Object.keys(registry3);
924
1258
 
925
1259
  // src/loadRules.ts
926
- import { readdirSync as readdirSync3, readFileSync as readFileSync2 } from "fs";
927
- import { join as join3 } from "path";
1260
+ import { readdirSync as readdirSync3, readFileSync as readFileSync4 } from "fs";
1261
+ import { join as join4 } from "path";
928
1262
  import { parse } from "yaml";
929
1263
  var SEVERITIES = ["critical", "high", "medium", "low"];
930
1264
  function validateRule(r, file) {
@@ -936,7 +1270,19 @@ function validateRule(r, file) {
936
1270
  if (!SEVERITIES.includes(r.severity)) errs.push(`bad severity '${r.severity}'`);
937
1271
  if (!r.title || typeof r.title !== "string") errs.push("missing title");
938
1272
  if (!r.component || !r.component.name || !r.component.type) errs.push("missing component.name/type");
939
- if (!r.detect || !Array.isArray(r.detect.modules) || typeof r.detect.nameRegex !== "string" || !Array.isArray(r.detect.triggerCalls)) {
1273
+ if (r.detect?.lang === "config") {
1274
+ if (!Array.isArray(r.detect.filePatterns) || r.detect.filePatterns.length === 0) {
1275
+ errs.push("detect.filePatterns must be a non-empty array when detect.lang is 'config'");
1276
+ } else {
1277
+ for (const pat of r.detect.filePatterns) {
1278
+ try {
1279
+ new RegExp(pat);
1280
+ } catch {
1281
+ errs.push(`detect.filePatterns contains an invalid regex: ${pat}`);
1282
+ }
1283
+ }
1284
+ }
1285
+ } else if (!r.detect || !Array.isArray(r.detect.modules) || typeof r.detect.nameRegex !== "string" || !Array.isArray(r.detect.triggerCalls)) {
940
1286
  errs.push("detect must have modules[], nameRegex (string), triggerCalls[]");
941
1287
  } else {
942
1288
  try {
@@ -957,7 +1303,7 @@ function loadRules(dir) {
957
1303
  const files = readdirSync3(dir).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")).sort();
958
1304
  const rules2 = [];
959
1305
  for (const f of files) {
960
- const raw = parse(readFileSync2(join3(dir, f), "utf8"));
1306
+ const raw = parse(readFileSync4(join4(dir, f), "utf8"));
961
1307
  validateRule(raw, f);
962
1308
  rules2.push(raw);
963
1309
  }
@@ -966,23 +1312,23 @@ function loadRules(dir) {
966
1312
 
967
1313
  // rules/index.ts
968
1314
  import { existsSync } from "fs";
969
- import { dirname, join as join4 } from "path";
1315
+ import { dirname, join as join5 } from "path";
970
1316
  import { fileURLToPath } from "url";
971
1317
  function bundledRulesDir() {
972
1318
  const here = dirname(fileURLToPath(import.meta.url));
973
- if (existsSync(join4(here, "stripe-webhook-raw-body.yaml"))) return here;
974
- const sub = join4(here, "rules");
975
- if (existsSync(join4(sub, "stripe-webhook-raw-body.yaml"))) return sub;
1319
+ if (existsSync(join5(here, "stripe-webhook-raw-body.yaml"))) return here;
1320
+ const sub = join5(here, "rules");
1321
+ if (existsSync(join5(sub, "stripe-webhook-raw-body.yaml"))) return sub;
976
1322
  return here;
977
1323
  }
978
1324
  var rules = loadRules(bundledRulesDir());
979
1325
 
980
1326
  // src/resolveRules.ts
981
1327
  import { existsSync as existsSync2 } from "fs";
982
- import { join as join5 } from "path";
1328
+ import { join as join6 } from "path";
983
1329
  function resolveRules(targetDir) {
984
1330
  const all = [...rules];
985
- const projDir = join5(targetDir, ".agent-research", "rules");
1331
+ const projDir = join6(targetDir, ".agent-research", "rules");
986
1332
  if (existsSync2(projDir)) {
987
1333
  const seen = new Set(all.map((r) => r.id));
988
1334
  for (const r of loadRules(projDir)) {
@@ -998,19 +1344,19 @@ function resolveRules(targetDir) {
998
1344
  }
999
1345
 
1000
1346
  // src/trustGraph/directory.ts
1001
- import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
1347
+ import { readFileSync as readFileSync5, existsSync as existsSync3 } from "fs";
1002
1348
  import { fileURLToPath as fileURLToPath2 } from "url";
1003
- import { join as join6 } from "path";
1349
+ import { join as join7 } from "path";
1004
1350
  import { parse as parse2 } from "yaml";
1005
1351
  var cache = null;
1006
1352
  function bundledPath() {
1007
1353
  const here = fileURLToPath2(new URL(".", import.meta.url));
1008
1354
  const candidates = [
1009
- join6(here, "programs", "directory.yaml"),
1355
+ join7(here, "programs", "directory.yaml"),
1010
1356
  // dist/programs/directory.yaml
1011
- join6(here, "..", "..", "programs", "directory.yaml"),
1357
+ join7(here, "..", "..", "programs", "directory.yaml"),
1012
1358
  // src/../../programs/
1013
- join6(here, "..", "programs", "directory.yaml")
1359
+ join7(here, "..", "programs", "directory.yaml")
1014
1360
  // fallback
1015
1361
  ];
1016
1362
  for (const c of candidates) {
@@ -1020,7 +1366,7 @@ function bundledPath() {
1020
1366
  }
1021
1367
  function loadDirectory(path = bundledPath()) {
1022
1368
  if (cache && path === bundledPath()) return cache;
1023
- const raw = parse2(readFileSync3(path, "utf8"));
1369
+ const raw = parse2(readFileSync5(path, "utf8"));
1024
1370
  if (!raw || !Array.isArray(raw.programs)) {
1025
1371
  throw new Error(`invalid program directory at ${path}: missing 'programs' array`);
1026
1372
  }
@@ -1091,14 +1437,14 @@ function isValidSolanaAddress(s) {
1091
1437
  }
1092
1438
 
1093
1439
  // src/trustGraph/programCache.ts
1094
- import { readFileSync as readFileSync4, writeFileSync, mkdirSync, existsSync as existsSync4 } from "fs";
1095
- import { join as join7, dirname as dirname2 } from "path";
1440
+ import { readFileSync as readFileSync6, writeFileSync, mkdirSync, existsSync as existsSync4 } from "fs";
1441
+ import { join as join8, dirname as dirname2 } from "path";
1096
1442
  import { homedir } from "os";
1097
1443
  var DEFAULT_TTL_HOURS = 168;
1098
1444
  var SCHEMA_VERSION = "1.0";
1099
1445
  function defaultCachePath() {
1100
1446
  const envOverride = process.env["BRAINBLAST_CACHE_PATH"];
1101
- return envOverride ?? join7(homedir(), ".brainblast", "program-cache.json");
1447
+ return envOverride ?? join8(homedir(), ".brainblast", "program-cache.json");
1102
1448
  }
1103
1449
  function emptyCache() {
1104
1450
  return { schemaVersion: SCHEMA_VERSION, entries: {} };
@@ -1107,7 +1453,7 @@ function loadProgramCache(cachePath) {
1107
1453
  const path = cachePath ?? defaultCachePath();
1108
1454
  if (!existsSync4(path)) return emptyCache();
1109
1455
  try {
1110
- const raw = JSON.parse(readFileSync4(path, "utf8"));
1456
+ const raw = JSON.parse(readFileSync6(path, "utf8"));
1111
1457
  if (raw?.schemaVersion !== SCHEMA_VERSION) {
1112
1458
  return emptyCache();
1113
1459
  }
@@ -1384,7 +1730,7 @@ function renderTrustGraphMd(g) {
1384
1730
  }
1385
1731
 
1386
1732
  // src/costAnalysis.ts
1387
- import { Project as Project2, SyntaxKind as SyntaxKind7 } from "ts-morph";
1733
+ import { Project as Project2, SyntaxKind as SyntaxKind9 } from "ts-morph";
1388
1734
  var LAMPORTS_PER_BYTE_YEAR = 3480;
1389
1735
  var EXEMPTION_THRESHOLD = 2;
1390
1736
  var OVERHEAD_BYTES = 128;
@@ -1465,11 +1811,11 @@ var KNOWN_FLOWS = [
1465
1811
  }
1466
1812
  ];
1467
1813
  var LOOP_NODE_KINDS = /* @__PURE__ */ new Set([
1468
- SyntaxKind7.ForStatement,
1469
- SyntaxKind7.ForOfStatement,
1470
- SyntaxKind7.ForInStatement,
1471
- SyntaxKind7.WhileStatement,
1472
- SyntaxKind7.DoStatement
1814
+ SyntaxKind9.ForStatement,
1815
+ SyntaxKind9.ForOfStatement,
1816
+ SyntaxKind9.ForInStatement,
1817
+ SyntaxKind9.WhileStatement,
1818
+ SyntaxKind9.DoStatement
1473
1819
  ]);
1474
1820
  var ARRAY_METHOD_LOOPS = /* @__PURE__ */ new Set(["map", "forEach", "flatMap", "reduce", "filter"]);
1475
1821
  function isInsideLoop(node) {
@@ -1477,12 +1823,12 @@ function isInsideLoop(node) {
1477
1823
  while (cur) {
1478
1824
  const k = cur.getKind?.();
1479
1825
  if (k !== void 0 && LOOP_NODE_KINDS.has(k)) {
1480
- return { scalable: true, note: `call is inside a ${SyntaxKind7[k]} \u2014 cost scales with loop iterations` };
1826
+ return { scalable: true, note: `call is inside a ${SyntaxKind9[k]} \u2014 cost scales with loop iterations` };
1481
1827
  }
1482
- if (k === SyntaxKind7.CallExpression) {
1828
+ if (k === SyntaxKind9.CallExpression) {
1483
1829
  const expr = cur.getExpression?.();
1484
- if (expr?.getKind?.() === SyntaxKind7.PropertyAccessExpression) {
1485
- const name = expr.asKind?.(SyntaxKind7.PropertyAccessExpression)?.getName?.();
1830
+ if (expr?.getKind?.() === SyntaxKind9.PropertyAccessExpression) {
1831
+ const name = expr.asKind?.(SyntaxKind9.PropertyAccessExpression)?.getName?.();
1486
1832
  if (name && ARRAY_METHOD_LOOPS.has(name)) {
1487
1833
  return { scalable: true, note: `call is inside .${name}() \u2014 cost scales with array length` };
1488
1834
  }
@@ -1496,7 +1842,7 @@ function detectPriorityFee(targetDir) {
1496
1842
  const project = new Project2({ skipAddingFilesFromTsConfig: true });
1497
1843
  for (const file of walk(targetDir)) {
1498
1844
  const sf = project.addSourceFileAtPath(file);
1499
- const calls = sf.getDescendantsOfKind(SyntaxKind7.CallExpression);
1845
+ const calls = sf.getDescendantsOfKind(SyntaxKind9.CallExpression);
1500
1846
  for (const ce of calls) {
1501
1847
  const expr = ce.getExpression();
1502
1848
  const text = expr.getText();
@@ -1524,22 +1870,22 @@ function detectAccountFlows(targetDir) {
1524
1870
  const importedModules = new Set(
1525
1871
  sf.getImportDeclarations().map((d) => d.getModuleSpecifierValue())
1526
1872
  );
1527
- for (const ce of sf.getDescendantsOfKind(SyntaxKind7.CallExpression)) {
1873
+ for (const ce of sf.getDescendantsOfKind(SyntaxKind9.CallExpression)) {
1528
1874
  const expr = ce.getExpression();
1529
- let callName4 = null;
1530
- if (expr.getKind() === SyntaxKind7.Identifier) {
1531
- callName4 = expr.getText();
1532
- } else if (expr.getKind() === SyntaxKind7.PropertyAccessExpression) {
1533
- callName4 = expr.asKind(SyntaxKind7.PropertyAccessExpression).getName();
1875
+ let callName5 = null;
1876
+ if (expr.getKind() === SyntaxKind9.Identifier) {
1877
+ callName5 = expr.getText();
1878
+ } else if (expr.getKind() === SyntaxKind9.PropertyAccessExpression) {
1879
+ callName5 = expr.asKind(SyntaxKind9.PropertyAccessExpression).getName();
1534
1880
  }
1535
- if (!callName4) continue;
1536
- const known = callIndex.get(callName4);
1881
+ if (!callName5) continue;
1882
+ const known = callIndex.get(callName5);
1537
1883
  if (!known) continue;
1538
1884
  if (!importedModules.has(known.module)) continue;
1539
1885
  const lamports = rentExemptMinimum(known.dataLen);
1540
1886
  const { scalable, note } = isInsideLoop(ce);
1541
1887
  flows.push({
1542
- call: callName4,
1888
+ call: callName5,
1543
1889
  module: known.module,
1544
1890
  accountType: known.accountType,
1545
1891
  file,
@@ -1626,10 +1972,72 @@ function renderCostReportMd(r) {
1626
1972
  return lines.join("\n");
1627
1973
  }
1628
1974
 
1975
+ // src/watch.ts
1976
+ import { watch as fsWatch } from "fs";
1977
+ function runIncrementalScan(targetDir, rules2, emit) {
1978
+ const start = Date.now();
1979
+ let changedRanges;
1980
+ try {
1981
+ changedRanges = getWorkingTreeChanges(targetDir);
1982
+ } catch (e) {
1983
+ emit({ type: "scan_error", message: e?.message ?? String(e) });
1984
+ return;
1985
+ }
1986
+ if (changedRanges.size === 0) {
1987
+ emit({ type: "scan_complete", filesChanged: 0, findings: 0, durationMs: Date.now() - start });
1988
+ return;
1989
+ }
1990
+ const { checks } = audit(targetDir, rules2, changedRanges);
1991
+ let findings = 0;
1992
+ for (const c of checks) {
1993
+ if (c.result === "pass") continue;
1994
+ findings++;
1995
+ emit({
1996
+ type: "finding",
1997
+ ruleId: c.ruleId,
1998
+ severity: c.severity,
1999
+ result: c.result,
2000
+ file: c.file,
2001
+ line: c.line,
2002
+ detail: c.detail,
2003
+ ...c.fix ? { fix: c.fix } : {}
2004
+ });
2005
+ }
2006
+ emit({ type: "scan_complete", filesChanged: changedRanges.size, findings, durationMs: Date.now() - start });
2007
+ }
2008
+ function startWatch(targetDir, opts = {}) {
2009
+ const debounceMs = opts.debounceMs ?? 300;
2010
+ const emit = opts.emit ?? ((e) => process.stdout.write(JSON.stringify(e) + "\n"));
2011
+ const rules2 = resolveRules(targetDir);
2012
+ let timer;
2013
+ const scheduleScan = () => {
2014
+ if (timer) clearTimeout(timer);
2015
+ timer = setTimeout(() => runIncrementalScan(targetDir, rules2, emit), debounceMs);
2016
+ };
2017
+ const watcher = fsWatch(targetDir, { recursive: true }, (_event, filename) => {
2018
+ if (!filename) return;
2019
+ const parts = filename.split(/[\\/]/);
2020
+ if (parts.some((p) => SKIP_DIRS.has(p))) return;
2021
+ scheduleScan();
2022
+ });
2023
+ emit({ type: "watch_started", targetDir });
2024
+ return {
2025
+ close: () => {
2026
+ if (timer) clearTimeout(timer);
2027
+ watcher.close();
2028
+ }
2029
+ };
2030
+ }
2031
+
1629
2032
  export {
1630
2033
  findCandidates,
2034
+ findConfigCandidates,
1631
2035
  runChecker,
1632
2036
  checkerKinds,
2037
+ getChangedRanges,
2038
+ getWorkingTreeChanges,
2039
+ fileChanged,
2040
+ rangeChanged,
1633
2041
  auditWithRule,
1634
2042
  audit,
1635
2043
  renderTest,
@@ -1655,5 +2063,7 @@ export {
1655
2063
  rentExemptMinimum,
1656
2064
  lamportsToSol,
1657
2065
  analyzeCosts,
1658
- renderCostReportMd
2066
+ renderCostReportMd,
2067
+ runIncrementalScan,
2068
+ startWatch
1659
2069
  };
package/dist/cli.js CHANGED
@@ -5,40 +5,176 @@ import {
5
5
  buildTrustGraph,
6
6
  cacheSize,
7
7
  defaultCachePath,
8
+ getChangedRanges,
8
9
  isValidSolanaAddress,
9
10
  loadProgramCache,
10
11
  renderCostReportMd,
11
12
  renderTrustGraphMd,
12
- resolveRules
13
- } from "./chunk-BZVZ3WAU.js";
13
+ resolveRules,
14
+ startWatch
15
+ } from "./chunk-P7K7NRVN.js";
14
16
 
15
17
  // src/cli.ts
16
- import { writeFileSync, mkdirSync } from "fs";
18
+ import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
19
+ import { join as join2 } from "path";
20
+
21
+ // src/memory.ts
22
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
17
23
  import { join } from "path";
24
+ var EMPTY_MEMORY = { schemaVersion: "1.0", lastRun: [], fixHistory: [] };
25
+ function memoryPath(targetDir2) {
26
+ return join(targetDir2, ".agent-research", "memory.json");
27
+ }
28
+ function loadMemory(targetDir2) {
29
+ const p = memoryPath(targetDir2);
30
+ if (!existsSync(p)) return { ...EMPTY_MEMORY, lastRun: [], fixHistory: [] };
31
+ try {
32
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
33
+ return {
34
+ schemaVersion: parsed.schemaVersion ?? "1.0",
35
+ lastRun: Array.isArray(parsed.lastRun) ? parsed.lastRun : [],
36
+ fixHistory: Array.isArray(parsed.fixHistory) ? parsed.fixHistory : []
37
+ };
38
+ } catch {
39
+ return { ...EMPTY_MEMORY, lastRun: [], fixHistory: [] };
40
+ }
41
+ }
42
+ function saveMemory(targetDir2, memory) {
43
+ mkdirSync(join(targetDir2, ".agent-research"), { recursive: true });
44
+ writeFileSync(memoryPath(targetDir2), JSON.stringify(memory, null, 2));
45
+ }
46
+ var snapshotKey = (e) => `${e.ruleId}::${e.file}::${e.exportName}`;
47
+ function precedentKey(c) {
48
+ return `${c.ruleId}::${c.file}`;
49
+ }
50
+ function updateMemory(memory, checks2, now = /* @__PURE__ */ new Date()) {
51
+ const prevByKey = new Map(memory.lastRun.map((e) => [snapshotKey(e), e]));
52
+ const fixedAt = now.toISOString().slice(0, 10);
53
+ const newFixEvents = [];
54
+ for (const c of checks2) {
55
+ const prev = prevByKey.get(snapshotKey(c));
56
+ if (prev?.result === "fail" && c.result !== "fail") {
57
+ newFixEvents.push({
58
+ ruleId: c.ruleId,
59
+ file: c.file,
60
+ exportName: c.exportName,
61
+ fixedAt,
62
+ detail: prev.detail
63
+ });
64
+ }
65
+ }
66
+ const fixHistory = [...memory.fixHistory, ...newFixEvents];
67
+ const precedents = /* @__PURE__ */ new Map();
68
+ for (const c of checks2) {
69
+ if (c.result !== "fail") continue;
70
+ const pk = precedentKey(c);
71
+ if (precedents.has(pk)) continue;
72
+ const matches = fixHistory.filter((e) => e.ruleId === c.ruleId && e.file !== c.file).sort((a, b) => a.fixedAt < b.fixedAt ? 1 : a.fixedAt > b.fixedAt ? -1 : 0);
73
+ if (matches[0]) {
74
+ precedents.set(pk, {
75
+ file: matches[0].file,
76
+ exportName: matches[0].exportName,
77
+ fixedAt: matches[0].fixedAt,
78
+ detail: matches[0].detail
79
+ });
80
+ }
81
+ }
82
+ const lastRun = checks2.map((c) => ({
83
+ ruleId: c.ruleId,
84
+ file: c.file,
85
+ exportName: c.exportName,
86
+ result: c.result,
87
+ detail: c.detail
88
+ }));
89
+ return { memory: { schemaVersion: "1.0", lastRun, fixHistory }, precedents };
90
+ }
91
+
92
+ // src/cli.ts
18
93
  var args = process.argv.slice(2);
19
94
  if (args[0] === "trust-graph") {
20
95
  await runTrustGraph(args.slice(1));
21
96
  process.exit(0);
22
97
  }
98
+ if (args[0] === "watch") {
99
+ const watchDir = args.find((a, i) => i > 0 && !a.startsWith("--")) ?? process.cwd();
100
+ startWatch(watchDir);
101
+ process.on("SIGINT", () => process.exit(0));
102
+ process.on("SIGTERM", () => process.exit(0));
103
+ await new Promise(() => {
104
+ });
105
+ }
23
106
  var ci = args.includes("--ci");
24
107
  var strict = args.includes("--strict");
25
- var targetDir = args.find((a) => !a.startsWith("--")) ?? process.cwd();
108
+ var sinceIdx = args.indexOf("--since");
109
+ var since = sinceIdx >= 0 ? args[sinceIdx + 1] : void 0;
110
+ var targetDir = args.find((a, i) => !a.startsWith("--") && args[i - 1] !== "--since") ?? process.cwd();
111
+ if (sinceIdx >= 0 && !since) {
112
+ console.error("error: --since requires a <ref> argument, e.g. --since origin/main");
113
+ process.exit(2);
114
+ }
26
115
  var rules = resolveRules(targetDir);
27
- var { checks, report } = audit(targetDir, rules);
116
+ var changedRanges;
117
+ if (since) {
118
+ try {
119
+ changedRanges = getChangedRanges(targetDir, since);
120
+ } catch (e) {
121
+ console.error(e.message ?? String(e));
122
+ process.exit(2);
123
+ }
124
+ }
125
+ var { checks, report } = audit(targetDir, rules, changedRanges);
126
+ if (!changedRanges) {
127
+ const memory = loadMemory(targetDir);
128
+ const { memory: nextMemory, precedents } = updateMemory(memory, checks);
129
+ for (const c of checks) {
130
+ const p = precedents.get(precedentKey(c));
131
+ if (p) c.precedent = p;
132
+ }
133
+ for (const rc of report.checks) {
134
+ const p = precedents.get(precedentKey(rc));
135
+ if (p) rc.precedent = p;
136
+ }
137
+ saveMemory(targetDir, nextMemory);
138
+ } else {
139
+ const memory = loadMemory(targetDir);
140
+ const { precedents } = updateMemory(memory, checks);
141
+ for (const c of checks) {
142
+ const p = precedents.get(precedentKey(c));
143
+ if (p) c.precedent = p;
144
+ }
145
+ for (const rc of report.checks) {
146
+ const p = precedents.get(precedentKey(rc));
147
+ if (p) rc.precedent = p;
148
+ }
149
+ }
28
150
  var costReport = analyzeCosts(targetDir);
29
151
  report.costAnalysis = costReport;
30
- var outDir = join(targetDir, ".agent-research");
31
- mkdirSync(outDir, { recursive: true });
32
- var reportPath = join(outDir, "report.json");
33
- writeFileSync(reportPath, JSON.stringify(report, null, 2));
34
- var costMdPath = join(outDir, "cost-analysis.md");
35
- writeFileSync(costMdPath, renderCostReportMd(costReport));
152
+ var outDir = join2(targetDir, ".agent-research");
153
+ mkdirSync2(outDir, { recursive: true });
154
+ var reportPath = join2(outDir, "report.json");
155
+ writeFileSync2(reportPath, JSON.stringify(report, null, 2));
156
+ var costMdPath = join2(outDir, "cost-analysis.md");
157
+ writeFileSync2(costMdPath, renderCostReportMd(costReport));
36
158
  console.log(`brainblast: scanned ${targetDir} with ${rules.length} rule(s)`);
37
159
  if (checks.length === 0) console.log(" (no catastrophic components detected)");
38
160
  for (const c of checks) {
39
161
  const tag = c.result === "pass" ? "PASS " : c.result === "fail" ? "FAIL " : "WARN ";
40
162
  console.log(` [${tag}] ${c.ruleId} ${c.file}:${c.line}`);
41
163
  console.log(` ${c.detail}`);
164
+ if (c.precedent) {
165
+ console.log(
166
+ ` memory: same issue (${c.ruleId}) was fixed in ${c.precedent.file} on ${c.precedent.fixedAt}`
167
+ );
168
+ }
169
+ if (c.fix) {
170
+ console.log(` fix: ${c.fix.summary}`);
171
+ if (c.fix.diff) {
172
+ for (const line of c.fix.diff.split("\n")) console.log(` ${line}`);
173
+ }
174
+ if (c.fix.suggestion) {
175
+ for (const line of c.fix.suggestion.split("\n")) console.log(` ${line}`);
176
+ }
177
+ }
42
178
  }
43
179
  var fails = checks.filter((c) => c.result === "fail").length;
44
180
  var cantTell = checks.filter((c) => c.result === "cant_tell").length;
package/dist/index.d.ts CHANGED
@@ -74,10 +74,29 @@ interface RustCandidate {
74
74
  /** tree-sitter SyntaxNode for the function body — available for precise queries */
75
75
  fnBodyNode: any;
76
76
  }
77
+ interface ConfigCandidate {
78
+ /** Source file (absolute path) */
79
+ filePath: string;
80
+ /** Raw file contents */
81
+ content: string;
82
+ /** Whether this file is tracked by git / not covered by .gitignore */
83
+ tracked: boolean;
84
+ }
77
85
  interface CheckOutcome {
78
86
  result: CheckResultKind;
79
87
  detail: string;
80
88
  }
89
+ interface Fix {
90
+ summary: string;
91
+ diff?: string;
92
+ suggestion?: string;
93
+ }
94
+ interface Precedent {
95
+ file: string;
96
+ exportName: string;
97
+ fixedAt: string;
98
+ detail: string;
99
+ }
81
100
  interface CheckResult extends CheckOutcome {
82
101
  ruleId: string;
83
102
  severity: Severity;
@@ -85,6 +104,10 @@ interface CheckResult extends CheckOutcome {
85
104
  file: string;
86
105
  line: number;
87
106
  exportName: string;
107
+ /** Present when result === "fail" and a vetted fixer for check.kind produced one. */
108
+ fix?: Fix;
109
+ /** Present when result === "fail" and the same rule was previously fixed elsewhere. */
110
+ precedent?: Precedent;
88
111
  }
89
112
  interface Rule {
90
113
  id: string;
@@ -100,8 +123,30 @@ interface Rule {
100
123
  modules: string[];
101
124
  nameRegex: string;
102
125
  triggerCalls: string[];
103
- /** Defaults to "typescript". Set to "rust" for Anchor/Rust checker kinds. */
104
- lang?: "typescript" | "rust";
126
+ /**
127
+ * Defaults to "typescript". Set to "rust" for Anchor/Rust checker kinds,
128
+ * or "config" for whole-file config/env audits (see `filePatterns`).
129
+ */
130
+ lang?: "typescript" | "rust" | "config";
131
+ /**
132
+ * Required when `lang: "config"`. Regexes (matched against the file path
133
+ * relative to the scan root) selecting which files this rule audits,
134
+ * e.g. `["(^|/)\\.env(\\.[^/]+)?$"]`. Ignored for "typescript"/"rust".
135
+ */
136
+ filePatterns?: string[];
137
+ /**
138
+ * When true, a module import from `modules` is a REQUIRED condition for
139
+ * detection: a candidate must be in a file that imports one of the listed
140
+ * modules, AND either its name matches nameRegex or its body calls a
141
+ * triggerCall. This prevents generic name-only matches (e.g. a Fastify
142
+ * middleware named "verifyJwt" that calls `request.jwtVerify()`) from
143
+ * triggering jose-specific rules.
144
+ *
145
+ * When false or omitted (default), detection is: nameRegex match OR
146
+ * triggerCall in body. Module import is not required, so rules like
147
+ * stripe-webhook can still catch handlers that don't import stripe directly.
148
+ */
149
+ requiresImport?: boolean;
105
150
  };
106
151
  check: {
107
152
  kind: string;
@@ -112,9 +157,18 @@ interface Rule {
112
157
  params?: Record<string, any>;
113
158
  };
114
159
  }
160
+ type Checker = (candidate: Candidate, params: any) => CheckOutcome;
161
+ type RustChecker = (candidate: RustCandidate, params: any) => CheckOutcome;
162
+ type ConfigChecker = (candidate: ConfigCandidate, params: any) => CheckOutcome;
163
+
164
+ type ChangedRanges = Map<string, Array<[number, number]>>;
165
+ declare function getChangedRanges(targetDir: string, ref: string): ChangedRanges;
166
+ declare function getWorkingTreeChanges(targetDir: string): ChangedRanges;
167
+ declare function fileChanged(ranges: ChangedRanges, file: string): boolean;
168
+ declare function rangeChanged(ranges: ChangedRanges, file: string, startLine: number, endLine: number): boolean;
115
169
 
116
- declare function auditWithRule(targetDir: string, rule: Rule): CheckResult[];
117
- declare function audit(targetDir: string, rules: Rule[]): {
170
+ declare function auditWithRule(targetDir: string, rule: Rule, changedRanges?: ChangedRanges): CheckResult[];
171
+ declare function audit(targetDir: string, rules: Rule[], changedRanges?: ChangedRanges): {
118
172
  checks: CheckResult[];
119
173
  report: {
120
174
  schemaVersion: string;
@@ -150,6 +204,8 @@ declare function audit(targetDir: string, rules: Rule[]): {
150
204
  low: number;
151
205
  };
152
206
  checks: {
207
+ precedent?: Precedent | undefined;
208
+ fix?: Fix | undefined;
153
209
  ruleId: string;
154
210
  severity: Severity;
155
211
  result: CheckResultKind;
@@ -183,11 +239,43 @@ declare function renderTest(kind: string, opts: {
183
239
  }): string;
184
240
  declare const testKinds: string[];
185
241
 
186
- declare function runChecker(kind: string, c: Candidate | RustCandidate, params: any): CheckOutcome;
242
+ declare function runChecker(kind: string, c: Candidate | RustCandidate | ConfigCandidate, params: any): CheckOutcome;
187
243
  declare const checkerKinds: string[];
188
244
 
189
245
  declare function findCandidates(targetDir: string, rule: Rule): Candidate[];
190
246
 
247
+ declare function findConfigCandidates(targetDir: string, rule: Rule): ConfigCandidate[];
248
+
249
+ type WatchEvent = {
250
+ type: "watch_started";
251
+ targetDir: string;
252
+ } | {
253
+ type: "scan_error";
254
+ message: string;
255
+ } | {
256
+ type: "finding";
257
+ ruleId: string;
258
+ severity: string;
259
+ result: "fail" | "cant_tell";
260
+ file: string;
261
+ line: number;
262
+ detail: string;
263
+ fix?: unknown;
264
+ } | {
265
+ type: "scan_complete";
266
+ filesChanged: number;
267
+ findings: number;
268
+ durationMs: number;
269
+ };
270
+ interface WatchOptions {
271
+ debounceMs?: number;
272
+ emit?: (event: WatchEvent) => void;
273
+ }
274
+ declare function runIncrementalScan(targetDir: string, rules: Rule[], emit: (e: WatchEvent) => void): void;
275
+ declare function startWatch(targetDir: string, opts?: WatchOptions): {
276
+ close: () => void;
277
+ };
278
+
191
279
  type UpgradeAuthorityKind = "renounced" | "single-key" | "multisig" | "dao" | "unknown";
192
280
  type UpgradeAuthoritySource = "directory" | "rpc" | "research";
193
281
  interface UpgradeAuthority {
@@ -317,4 +405,4 @@ declare function isEntryExpired(entry: ProgramCacheEntry, ttlHoursOverride?: num
317
405
  */
318
406
  declare function cacheSize(cache: ProgramCache, ttlHoursOverride?: number): number;
319
407
 
320
- export { type AccountFlow, type AuditRef, type BuildOpts, type Candidate, type CheckOutcome, type CheckResult, type CheckResultKind, type CostReport, DEFAULT_TTL_HOURS, type OnChainProgram, type ParityNote, type PriorityFeePosture, type ProgramCache, type ProgramCacheEntry, type Recoverability, type Rule, type RustAccountField, type RustCandidate, type Severity, type TrustGraph, type UpgradeAuthority, type UpgradeAuthorityKind, type UpgradeAuthoritySource, type VerifiedBuildState, analyzeCosts, audit, auditWithRule, base58Decode, base58Encode, buildTrustGraph, rules as bundledRules, cacheSize, checkerKinds, defaultCachePath, findCandidates, generateTestForResult, getCacheEntry, getCacheEntryMeta, isEntryExpired, isValidSolanaAddress, lamportsToSol, loadDirectory, loadProgramCache, loadRules, putCacheEntry, renderCostReportMd, renderTest, renderTrustGraphMd, rentExemptMinimum, resolveRules, runChecker, saveProgramCache, testKinds };
408
+ export { type AccountFlow, type AuditRef, type BuildOpts, type Candidate, type ChangedRanges, type CheckOutcome, type CheckResult, type CheckResultKind, type Checker, type ConfigCandidate, type ConfigChecker, type CostReport, DEFAULT_TTL_HOURS, type OnChainProgram, type ParityNote, type PriorityFeePosture, type ProgramCache, type ProgramCacheEntry, type Recoverability, type Rule, type RustAccountField, type RustCandidate, type RustChecker, type Severity, type TrustGraph, type UpgradeAuthority, type UpgradeAuthorityKind, type UpgradeAuthoritySource, type VerifiedBuildState, type WatchEvent, type WatchOptions, analyzeCosts, audit, auditWithRule, base58Decode, base58Encode, buildTrustGraph, rules as bundledRules, cacheSize, checkerKinds, defaultCachePath, fileChanged, findCandidates, findConfigCandidates, generateTestForResult, getCacheEntry, getCacheEntryMeta, getChangedRanges, getWorkingTreeChanges, isEntryExpired, isValidSolanaAddress, lamportsToSol, loadDirectory, loadProgramCache, loadRules, putCacheEntry, rangeChanged, renderCostReportMd, renderTest, renderTrustGraphMd, rentExemptMinimum, resolveRules, runChecker, runIncrementalScan, saveProgramCache, startWatch, testKinds };
package/dist/index.js CHANGED
@@ -9,9 +9,13 @@ import {
9
9
  cacheSize,
10
10
  checkerKinds,
11
11
  defaultCachePath,
12
+ fileChanged,
12
13
  findCandidates,
14
+ findConfigCandidates,
13
15
  getCacheEntry,
14
16
  getCacheEntryMeta,
17
+ getChangedRanges,
18
+ getWorkingTreeChanges,
15
19
  isEntryExpired,
16
20
  isValidSolanaAddress,
17
21
  lamportsToSol,
@@ -19,6 +23,7 @@ import {
19
23
  loadProgramCache,
20
24
  loadRules,
21
25
  putCacheEntry,
26
+ rangeChanged,
22
27
  renderCostReportMd,
23
28
  renderTest,
24
29
  renderTrustGraphMd,
@@ -26,9 +31,11 @@ import {
26
31
  resolveRules,
27
32
  rules,
28
33
  runChecker,
34
+ runIncrementalScan,
29
35
  saveProgramCache,
36
+ startWatch,
30
37
  testKinds
31
- } from "./chunk-BZVZ3WAU.js";
38
+ } from "./chunk-P7K7NRVN.js";
32
39
 
33
40
  // src/generate.ts
34
41
  import { writeFileSync, mkdirSync } from "fs";
@@ -55,10 +62,14 @@ export {
55
62
  cacheSize,
56
63
  checkerKinds,
57
64
  defaultCachePath,
65
+ fileChanged,
58
66
  findCandidates,
67
+ findConfigCandidates,
59
68
  generateTestForResult,
60
69
  getCacheEntry,
61
70
  getCacheEntryMeta,
71
+ getChangedRanges,
72
+ getWorkingTreeChanges,
62
73
  isEntryExpired,
63
74
  isValidSolanaAddress,
64
75
  lamportsToSol,
@@ -66,12 +77,15 @@ export {
66
77
  loadProgramCache,
67
78
  loadRules,
68
79
  putCacheEntry,
80
+ rangeChanged,
69
81
  renderCostReportMd,
70
82
  renderTest,
71
83
  renderTrustGraphMd,
72
84
  rentExemptMinimum,
73
85
  resolveRules,
74
86
  runChecker,
87
+ runIncrementalScan,
75
88
  saveProgramCache,
89
+ startWatch,
76
90
  testKinds
77
91
  };
@@ -0,0 +1,32 @@
1
+ # Pure-data rule (facts). Binds to vetted templates by `check.kind`/`test.kind`.
2
+ id: env-secrets-committed
3
+ severity: critical
4
+ title: Secret-looking values are not committed to source control
5
+ component:
6
+ name: Environment configuration
7
+ type: Config
8
+ version: unversioned
9
+ sourceUrl: https://12factor.net/config
10
+ detect:
11
+ lang: config
12
+ # .env, .env.local, .env.production, etc. — but not .env.example/.sample/.template,
13
+ # which are meant to be committed and document expected keys with placeholders.
14
+ filePatterns:
15
+ - "(^|/)\\.env(\\.(?!example$|sample$|template$)[^/]+)?$"
16
+ check:
17
+ kind: env-secrets-committed
18
+ params:
19
+ # Key names that typically hold credentials/secrets.
20
+ secretKeyPattern: "(SECRET|PRIVATE_KEY|API_KEY|ACCESS_KEY|TOKEN|PASSWORD|CREDENTIAL)"
21
+ # Values that look like placeholders, not real secrets — these are fine to commit.
22
+ placeholderPattern: "^(your[_-]|xxx|changeme|change[_-]?me|replace|example|<|sk_test_|pk_test_|test[_-]|dummy|placeholder|\\*+$|\\.\\.\\.$)"
23
+ ignoredDetail: >-
24
+ File is git-ignored and not committed to source control.
25
+ passDetail: >-
26
+ File is tracked but contains no secret-looking values (placeholders only).
27
+ failDetailPrefix: >-
28
+ This file is committed to source control and contains what look like real
29
+ secret values. Anyone with read access to the repo (including forks) can
30
+ read these credentials
31
+ test:
32
+ kind: none
@@ -14,7 +14,19 @@ detect:
14
14
  # Privy handlers are still picked up by the @privy-io/* / jose / jsonwebtoken
15
15
  # imports, and by triggerCalls like decodeJwt / jwtVerify.
16
16
  nameRegex: "privy|jwt"
17
- triggerCalls: [decodeJwt, jwtVerify, verify, decode]
17
+ # Only decodeJwt triggers detection; it's specific to jose and unambiguously
18
+ # indicates a decode-only pattern. jwtVerify/verify/decode were removed because:
19
+ # - "verify" matches Node.js crypto.verify(), jsonwebtoken jwt.verify(), etc.
20
+ # - "decode" matches bs58.decode() (Solana), Buffer.from(), etc.
21
+ # - "jwtVerify" matches Fastify's request.jwtVerify() plugin method, which is
22
+ # not a jose-based call and requires no audience/issuer options.
23
+ # Functions that import jose/@privy-io/* and are named with "privy|jwt" are still
24
+ # detected via requiresImport + (importsModule && hasName), the primary detection path.
25
+ triggerCalls: [decodeJwt]
26
+ # A module import is REQUIRED to be a candidate. Without this guard, a Fastify
27
+ # middleware named "verifyJwt" (which calls request.jwtVerify(), a completely
28
+ # different library) would match the nameRegex and be flagged as missing aud/iss.
29
+ requiresImport: true
18
30
  check:
19
31
  kind: required-call-with-options
20
32
  params:
@@ -11,6 +11,11 @@ detect:
11
11
  modules: [stripe]
12
12
  nameRegex: webhook
13
13
  triggerCalls: [constructEvent]
14
+ # Require the file to import stripe. Without this guard, any function whose
15
+ # name contains "webhook" — even LemonSqueezy, Polar, or Sendgrid handlers —
16
+ # is flagged for missing stripe.webhooks.constructEvent, producing false
17
+ # positives on non-Stripe payment integrations.
18
+ requiresImport: true
14
19
  check:
15
20
  kind: positional-arg-identity
16
21
  params:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainblast",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "Deterministic auditor for catastrophic AI-integration bugs: scan a repo, find the silent money/auth traps, and generate the behavioral test that proves they're fixed.",
6
6
  "keywords": [