@node9/proxy 2.10.0 → 2.12.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 (4) hide show
  1. package/README.md +13 -7
  2. package/dist/cli.js +258 -34
  3. package/dist/cli.mjs +258 -34
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -97,13 +97,13 @@ node9 scan-repo <owner/repo> --json # machine-readable
97
97
 
98
98
  What it checks:
99
99
 
100
- | Check | Flags |
101
- | -------- | ---------------------------------------------------------------------------- |
102
- | **CI-1** | committed agent config that pre-authorizes broad tools or runs remote hooks |
103
- | **CI-2** | injectable agent workflows — an outsider can trigger the agent and hijack it |
104
- | **CI-3** | unpinned / `@latest` MCP servers or inline credentials (supply chain) |
105
- | **CI-4** | secrets an injected agent could exfiltrate |
106
- | **CI-6** | poisoned or dangerous instructions in `CLAUDE.md` / `AGENTS.md` / skills |
100
+ | Check | Flags |
101
+ | -------- | -------------------------------------------------------------------------------- |
102
+ | **CI-1** | committed agent config that pre-authorizes broad tools or runs remote hooks |
103
+ | **CI-2** | injectable agent workflows — an outsider can trigger the agent and hijack it |
104
+ | **CI-3** | unpinned / `@latest` MCP servers or inline credentials (supply chain) |
105
+ | **CI-4** | secrets an injected agent could exfiltrate |
106
+ | **CI-6** | poisoned or dangerous instructions in `CLAUDE.md` / `AGENTS.md` / `.cursorrules` |
107
107
 
108
108
  **Gate every PR** — the same engine as a GitHub Action, so a hijackable config can't get merged:
109
109
 
@@ -112,8 +112,14 @@ What it checks:
112
112
  - uses: node9-ai/node9-proxy@v2
113
113
  with:
114
114
  fail-on: high # or 'never' to just comment
115
+ fail-on-scope: introduced # only what THIS PR added; 'all' (default) judges the whole repo
115
116
  ```
116
117
 
118
+ `fail-on-scope: introduced` is what makes the gate adoptable on a repository that already
119
+ has findings: the PR comment leads with what the change introduced, pre-existing findings
120
+ stay listed but do not block, and a base commit that cannot be read falls back to judging
121
+ everything rather than passing.
122
+
117
123
  Marketplace: **[node9 Agent Security](https://github.com/marketplace/actions/node9-agent-security)**
118
124
 
119
125
  Running it? Add the **[`scanned by node9` badge](docs/badges.md)** to your README.
package/dist/cli.js CHANGED
@@ -57936,6 +57936,50 @@ function readLocalTree(dir) {
57936
57936
  }
57937
57937
  return { source: root, files, notes };
57938
57938
  }
57939
+ function readGitRefTree(dir, ref) {
57940
+ const root = dir.replace(/^~/, process.env.HOME ?? "~");
57941
+ if (!ref || ref.startsWith("-")) return null;
57942
+ const git = (args) => {
57943
+ try {
57944
+ return (0, import_node_child_process.execFileSync)("git", ["-C", root, ...args], {
57945
+ encoding: "utf8",
57946
+ stdio: ["ignore", "pipe", "ignore"],
57947
+ maxBuffer: 32 * 1024 * 1024,
57948
+ timeout: 2e4
57949
+ });
57950
+ } catch {
57951
+ return null;
57952
+ }
57953
+ };
57954
+ const sha = git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`])?.trim();
57955
+ if (!sha) return null;
57956
+ const listing = git(["ls-tree", "-r", "--name-only", "-z", sha]);
57957
+ if (listing === null) return null;
57958
+ const all = listing.split("\0").filter(Boolean);
57959
+ const notes = [];
57960
+ const files = [];
57961
+ const seen = /* @__PURE__ */ new Set();
57962
+ const present = new Set(all);
57963
+ const add = (rel) => {
57964
+ if (seen.has(rel) || !present.has(rel)) return;
57965
+ seen.add(rel);
57966
+ const content = git(["show", `${sha}:${rel}`]);
57967
+ if (content !== null) files.push({ path: rel, content });
57968
+ };
57969
+ for (const rel of SURFACE_FILES) add(rel);
57970
+ const nested = all.filter((rel) => SURFACE_BASENAME.test(rel) && !isIgnoredDir(rel));
57971
+ if (nested.length > MAX_SURFACE_FILES) {
57972
+ notes.push(
57973
+ `repo is large \u2014 some agent-surface files may be INCOMPLETE (capped at ${MAX_SURFACE_FILES} files).`
57974
+ );
57975
+ }
57976
+ for (const rel of nested.slice(0, MAX_SURFACE_FILES)) add(rel);
57977
+ for (const rel of all) {
57978
+ if (rel.startsWith(`${WORKFLOW_DIR}/`) && /\.ya?ml$/.test(rel) && !rel.slice(WORKFLOW_DIR.length + 1).includes("/"))
57979
+ add(rel);
57980
+ }
57981
+ return { source: `${root}@${ref}`, files, notes };
57982
+ }
57939
57983
  async function fetchTree(input, onProgress) {
57940
57984
  if (isLocalPath(input)) return readLocalTree(input);
57941
57985
  const parsed = parseRepoUrl(input);
@@ -58357,6 +58401,8 @@ function analyzeWorkflow(path77, content) {
58357
58401
  const title = severity === "critical" || severity === "high" ? "Injectable agent workflow \u2014 untrusted input reaches a tool-using agent with secrets" : severity === "medium" ? "Agent workflow with a risky pattern (partially mitigated)" : "Agent workflow on a privileged trigger \u2014 review the actor gate";
58358
58402
  return {
58359
58403
  check: "CI-2",
58404
+ // One verdict per workflow file — the finding IS the file's reachability score.
58405
+ rule: "CI-2.injectable-workflow",
58360
58406
  dimension: "workflows",
58361
58407
  severity,
58362
58408
  title,
@@ -58453,6 +58499,7 @@ function analyzeWorkflowSecrets(path77, content) {
58453
58499
  if (!worst) return null;
58454
58500
  return {
58455
58501
  check: "CI-4",
58502
+ rule: "CI-4.agent-reachable-secret",
58456
58503
  dimension: "data",
58457
58504
  severity: worst.severity,
58458
58505
  title: worst.severity === "advisory" ? "Secrets reachable by the agent \u2014 hardening" : "Exfiltratable secrets reachable by an injectable agent",
@@ -58499,6 +58546,10 @@ function analyzeAgentConfig(path77, content) {
58499
58546
  const high = remoteExec || unpinned;
58500
58547
  findings.push({
58501
58548
  check: "CI-1",
58549
+ rule: "CI-1.hook-remote-code",
58550
+ // Identity is the command itself: the same hook keeps its identity when its
58551
+ // severity changes (pinned → unpinned), and two different hooks stay distinct.
58552
+ locator: cmd,
58502
58553
  dimension: "toolRules",
58503
58554
  severity: high ? "high" : "medium",
58504
58555
  title: high ? "Agent hook runs UNPINNED/remote third-party code on every action" : "Agent hook runs third-party code in the agent hot path",
@@ -58519,6 +58570,9 @@ function analyzeAgentConfig(path77, content) {
58519
58570
  const hasBackstop = deny.some((d) => /Bash|Write|Edit/.test(d));
58520
58571
  findings.push({
58521
58572
  check: "CI-1",
58573
+ rule: "CI-1.broad-allow",
58574
+ // File-level: one finding per config file. Adding a SECOND broad allow makes the
58575
+ // same statement about the same file, so it must not read as a new finding.
58522
58576
  dimension: "toolRules",
58523
58577
  severity: hasBackstop ? "medium" : "high",
58524
58578
  title: hasBackstop ? "Committed agent config pre-authorizes broad tools" : "Committed agent config pre-authorizes broad tools with no deny backstop",
@@ -58552,6 +58606,8 @@ function analyzeMcpServers(servers, path77) {
58552
58606
  if (/\bnpx\b/.test(argv) && (/@latest\b/.test(argv) || !/@\d/.test(argv))) {
58553
58607
  findings.push({
58554
58608
  check: "CI-3",
58609
+ rule: "CI-3.mcp-unpinned",
58610
+ locator: name,
58555
58611
  dimension: "mcp",
58556
58612
  severity: "medium",
58557
58613
  title: `MCP server "${name}" runs an unpinned executable`,
@@ -58566,6 +58622,8 @@ function analyzeMcpServers(servers, path77) {
58566
58622
  if (hit) {
58567
58623
  findings.push({
58568
58624
  check: "CI-3",
58625
+ rule: "CI-3.mcp-inline-credential",
58626
+ locator: `${name}.env.${k}`,
58569
58627
  dimension: "mcp",
58570
58628
  severity: "high",
58571
58629
  title: `MCP server "${name}" has an inline credential`,
@@ -58603,6 +58661,10 @@ function analyzeCodexConfig(path77, content) {
58603
58661
  ].filter((s) => s !== null);
58604
58662
  findings.push({
58605
58663
  check: "CI-1",
58664
+ // One finding per Codex config; `danger-full-access` vs `approval_policy = never`
58665
+ // are two severities of the same statement, so tightening one is a de-escalation
58666
+ // of THIS finding rather than the removal of one and the arrival of another.
58667
+ rule: "CI-1.codex-unsafe-defaults",
58606
58668
  dimension: "toolRules",
58607
58669
  severity: fullAccess ? "high" : "medium",
58608
58670
  title: fullAccess ? "Codex config grants a full-access sandbox" : "Codex config never requires approval",
@@ -58662,8 +58724,17 @@ function decodeSuspiciousBase64(text) {
58662
58724
  }
58663
58725
  return out;
58664
58726
  }
58665
- function mk(severity, title, signals, fix, path77) {
58666
- return { check: "CI-6", dimension: "instructions", severity, title, file: path77, signals, fix };
58727
+ function mk(rule, severity, title, signals, fix, path77) {
58728
+ return {
58729
+ check: "CI-6",
58730
+ rule,
58731
+ dimension: "instructions",
58732
+ severity,
58733
+ title,
58734
+ file: path77,
58735
+ signals,
58736
+ fix
58737
+ };
58667
58738
  }
58668
58739
  function analyzeInstructionFile(path77, content) {
58669
58740
  const findings = [];
@@ -58671,6 +58742,7 @@ function analyzeInstructionFile(path77, content) {
58671
58742
  if (TAG_CHARS.test(content))
58672
58743
  findings.push(
58673
58744
  mk(
58745
+ "CI-6.unicode-tag-chars",
58674
58746
  "critical",
58675
58747
  "Unicode tag characters in an agent instruction file",
58676
58748
  [
@@ -58683,6 +58755,7 @@ function analyzeInstructionFile(path77, content) {
58683
58755
  if (BIDI_OVERRIDE.test(content))
58684
58756
  findings.push(
58685
58757
  mk(
58758
+ "CI-6.bidi-override",
58686
58759
  "critical",
58687
58760
  "Bidirectional override characters in an agent instruction file",
58688
58761
  [
@@ -58695,6 +58768,7 @@ function analyzeInstructionFile(path77, content) {
58695
58768
  else if (BIDI_EMBED_ISOLATE.test(content))
58696
58769
  findings.push(
58697
58770
  mk(
58771
+ "CI-6.bidi-formatting",
58698
58772
  "advisory",
58699
58773
  "Bidirectional formatting characters in an agent instruction file",
58700
58774
  [
@@ -58709,6 +58783,7 @@ function analyzeInstructionFile(path77, content) {
58709
58783
  const revealed = OVERRIDE_RE.test(stripZeroWidth(content)) && !OVERRIDE_RE.test(content);
58710
58784
  findings.push(
58711
58785
  mk(
58786
+ "CI-6.zero-width",
58712
58787
  revealed ? "critical" : "medium",
58713
58788
  "Zero-width characters splitting text in an agent instruction file",
58714
58789
  [
@@ -58725,6 +58800,7 @@ function analyzeInstructionFile(path77, content) {
58725
58800
  const m = ov || ovEnc;
58726
58801
  findings.push(
58727
58802
  mk(
58803
+ "CI-6.prompt-override",
58728
58804
  ovEnc ? "critical" : "high",
58729
58805
  "Prompt-override directive in an agent instruction file",
58730
58806
  [
@@ -58739,6 +58815,7 @@ function analyzeInstructionFile(path77, content) {
58739
58815
  if (fo && !inHumanSection(content, fo.index) && !isNegated(content, fo.index)) {
58740
58816
  findings.push(
58741
58817
  mk(
58818
+ "CI-6.fetch-and-obey",
58742
58819
  "medium",
58743
58820
  "Instruction directs the agent to fetch and run remote code",
58744
58821
  [`\`${fo[0].slice(0, 70).trim()}\` \u2014 fetch-and-obey, outside an install/setup section`],
@@ -58751,6 +58828,7 @@ function analyzeInstructionFile(path77, content) {
58751
58828
  if (sp && !isNegated(content, sp.index)) {
58752
58829
  findings.push(
58753
58830
  mk(
58831
+ "CI-6.secret-path",
58754
58832
  "medium",
58755
58833
  "Instruction points the agent at credential material",
58756
58834
  [`references \`${sp[0].slice(0, 50).trim()}\` \u2014 directs the agent toward secrets`],
@@ -58763,6 +58841,7 @@ function analyzeInstructionFile(path77, content) {
58763
58841
  if (ex && !inHumanSection(content, ex.index) && !isNegated(content, ex.index)) {
58764
58842
  findings.push(
58765
58843
  mk(
58844
+ "CI-6.exfil-directive",
58766
58845
  "medium",
58767
58846
  "Instruction directs the agent to send data to an external endpoint",
58768
58847
  [`\`${ex[0].slice(0, 70).trim()}\` \u2014 possible exfiltration directive`],
@@ -58774,8 +58853,82 @@ function analyzeInstructionFile(path77, content) {
58774
58853
  return findings;
58775
58854
  }
58776
58855
 
58856
+ // src/ci-check/diff.ts
58857
+ var import_node_crypto = require("crypto");
58858
+ function fingerprintOf(f) {
58859
+ const key = JSON.stringify([f.rule, f.file, f.locator ?? "", f.ordinal ?? 0]);
58860
+ return (0, import_node_crypto.createHash)("sha256").update(key).digest("hex").slice(0, 16);
58861
+ }
58862
+ function assignOrdinals(findings) {
58863
+ const seen = /* @__PURE__ */ new Map();
58864
+ for (const f of findings) {
58865
+ const key = JSON.stringify([f.rule, f.file, f.locator ?? ""]);
58866
+ const n = seen.get(key) ?? 0;
58867
+ if (n > 0) f.ordinal = n;
58868
+ seen.set(key, n + 1);
58869
+ }
58870
+ return findings;
58871
+ }
58872
+ function worstOf(severities) {
58873
+ let worst = null;
58874
+ for (const s of severities) {
58875
+ if (!worst || SEVERITY_RANK2[s] > SEVERITY_RANK2[worst]) worst = s;
58876
+ }
58877
+ return worst;
58878
+ }
58879
+ function baseStateOf(base) {
58880
+ if (!base) return "did-not-run";
58881
+ return base.incomplete ? "incomplete" : "ok";
58882
+ }
58883
+ function diffScans(base, head) {
58884
+ const state = baseStateOf(base);
58885
+ if (state !== "ok" || !base) {
58886
+ return {
58887
+ base: state,
58888
+ added: [],
58889
+ removed: [],
58890
+ unchanged: [...head.findings],
58891
+ escalated: [],
58892
+ worstIntroduced: head.worst,
58893
+ incomplete: true
58894
+ };
58895
+ }
58896
+ const baseByFp = /* @__PURE__ */ new Map();
58897
+ for (const f of base.findings) baseByFp.set(fingerprintOf(f), f);
58898
+ const added = [];
58899
+ const unchanged = [];
58900
+ const escalated = [];
58901
+ const matched = /* @__PURE__ */ new Set();
58902
+ for (const f of head.findings) {
58903
+ const fp = fingerprintOf(f);
58904
+ const prior = baseByFp.get(fp);
58905
+ if (!prior) {
58906
+ added.push(f);
58907
+ continue;
58908
+ }
58909
+ matched.add(fp);
58910
+ if (SEVERITY_RANK2[f.severity] > SEVERITY_RANK2[prior.severity]) {
58911
+ escalated.push({ finding: f, from: prior.severity, to: f.severity });
58912
+ } else {
58913
+ unchanged.push(f);
58914
+ }
58915
+ }
58916
+ const removed = base.findings.filter((f) => !matched.has(fingerprintOf(f)));
58917
+ return {
58918
+ base: state,
58919
+ added,
58920
+ removed,
58921
+ unchanged,
58922
+ escalated,
58923
+ worstIntroduced: worstOf([...added.map((f) => f.severity), ...escalated.map((e) => e.to)]),
58924
+ // The head side of the same guard: a scan that could not read every file has not
58925
+ // earned the word "clean", however trustworthy the base was.
58926
+ incomplete: head.incomplete
58927
+ };
58928
+ }
58929
+
58777
58930
  // src/ci-check/index.ts
58778
- function worstOf(findings) {
58931
+ function worstOf2(findings) {
58779
58932
  let worst = null;
58780
58933
  for (const f of findings) {
58781
58934
  if (!worst || SEVERITY_RANK2[f.severity] > SEVERITY_RANK2[worst]) worst = f.severity;
@@ -58809,11 +58962,12 @@ function scanTree(tree) {
58809
58962
  notes.push(`checker degraded on ${file.path}: ${err2?.message ?? "error"}`);
58810
58963
  }
58811
58964
  }
58965
+ assignOrdinals(findings);
58812
58966
  findings.sort(
58813
58967
  (a, b) => SEVERITY_RANK2[b.severity] - SEVERITY_RANK2[a.severity] || a.file.localeCompare(b.file)
58814
58968
  );
58815
58969
  const incomplete = notes.some((nt) => /may be INCOMPLETE/i.test(nt));
58816
- return { source: tree.source, findings, inspected, notes, worst: worstOf(findings), incomplete };
58970
+ return { source: tree.source, findings, inspected, notes, worst: worstOf2(findings), incomplete };
58817
58971
  }
58818
58972
  async function scanRepo(input, onProgress) {
58819
58973
  const tree = await fetchTree(input, onProgress);
@@ -58867,12 +59021,37 @@ function renderCta(res) {
58867
59021
  function ownedHint(source) {
58868
59022
  return source.startsWith("/") || source.startsWith(".") || source.startsWith("~");
58869
59023
  }
58870
- function renderScan(res) {
59024
+ function findingMd(f, L) {
59025
+ L.push(`**${ICON2[f.severity]} ${f.severity.toUpperCase()} \u2014 ${f.title}**`);
59026
+ L.push(`\`${f.file}${f.line ? ":" + f.line : ""}\` \xB7 ${f.rule}`);
59027
+ L.push("");
59028
+ for (const s of f.signals) L.push(`- ${s}`);
59029
+ if (f.mitigations?.length) L.push(`- _mitigated:_ ${f.mitigations.join("; ")}`);
59030
+ L.push("");
59031
+ L.push(`\u2192 **Fix:** ${f.fix}`);
59032
+ L.push("");
59033
+ }
59034
+ function baseWarning(base) {
59035
+ return base === "did-not-run" ? '\u26A0\uFE0F **Could not read the base commit**, so nothing below can be called "new" \u2014 every finding in this repo is listed. (A shallow clone is the usual cause: fetch the base ref.)' : "\u26A0\uFE0F **The base scan could not read every file**, so a finding missing from it would look new. Every finding in this repo is listed instead.";
59036
+ }
59037
+ function renderScan(res, diff) {
58871
59038
  const L = [];
58872
59039
  const n = res.findings.length;
58873
59040
  const head = res.worst === "critical" || res.worst === "high" ? import_chalk32.default.red.bold("\u26A0\uFE0F agent-security risk found") : res.worst ? import_chalk32.default.yellow("agent-security notes") : res.incomplete ? import_chalk32.default.yellow.bold("\u26A0\uFE0F INCOMPLETE \u2014 could not read all files") : import_chalk32.default.green("\u2705 agent-security: clean");
58874
59041
  L.push(`\u{1F6E1}\uFE0F ${import_chalk32.default.bold("node9 scan-repo")} \xB7 ${res.source} \xB7 ${head}`);
58875
59042
  L.push(import_chalk32.default.gray(` inspected ${res.inspected.length} config file(s), ${n} finding(s)`));
59043
+ if (diff) {
59044
+ const introduced = diff.added.length + diff.escalated.length;
59045
+ L.push(
59046
+ diff.base !== "ok" ? import_chalk32.default.yellow.bold(
59047
+ ` \u26A0\uFE0F base ${diff.base === "did-not-run" ? "could not be read" : "scan was incomplete"} \u2014 cannot say what is new; showing everything`
59048
+ ) : introduced > 0 ? import_chalk32.default.red.bold(
59049
+ ` \u26A0\uFE0F this change introduced ${introduced} finding(s)` + (diff.escalated.length ? ` (${diff.escalated.length} by widening an existing one)` : "")
59050
+ ) : import_chalk32.default.green(
59051
+ ` \u2705 this change introduced nothing` + (diff.unchanged.length ? ` (${diff.unchanged.length} pre-existing)` : "") + (diff.removed.length ? `, and fixed ${diff.removed.length}` : "")
59052
+ )
59053
+ );
59054
+ }
58876
59055
  if (res.incomplete) {
58877
59056
  const rateLimited = res.notes.some((nt) => /rate limit/i.test(nt));
58878
59057
  L.push(
@@ -58912,7 +59091,7 @@ function renderScan(res) {
58912
59091
  L.push(...renderCta(res));
58913
59092
  return L.join("\n");
58914
59093
  }
58915
- function renderScanMarkdown(res) {
59094
+ function renderScanMarkdown(res, diff) {
58916
59095
  const L = [];
58917
59096
  const status = res.worst === "critical" || res.worst === "high" ? "\u26A0\uFE0F" : res.worst ? "\u{1F7E1}" : res.incomplete ? "\u26A0\uFE0F" : "\u2705";
58918
59097
  L.push(`### \u{1F6E1}\uFE0F node9 agent-security \xB7 \`${res.source}\` \xB7 ${status}`);
@@ -58921,25 +59100,57 @@ function renderScanMarkdown(res) {
58921
59100
  `Inspected ${res.inspected.length} config file(s) \xB7 **${res.findings.length} finding(s)**`
58922
59101
  );
58923
59102
  L.push("");
58924
- for (const f of res.findings) {
58925
- L.push(`**${ICON2[f.severity]} ${f.severity.toUpperCase()} \u2014 ${f.title}**`);
58926
- L.push(`\`${f.file}${f.line ? ":" + f.line : ""}\` \xB7 ${f.check}`);
58927
- L.push("");
58928
- for (const s of f.signals) L.push(`- ${s}`);
58929
- if (f.mitigations?.length) L.push(`- _mitigated:_ ${f.mitigations.join("; ")}`);
58930
- L.push("");
58931
- L.push(`\u2192 **Fix:** ${f.fix}`);
59103
+ if (diff && diff.base === "ok") {
59104
+ const introduced = [...diff.added, ...diff.escalated.map((e) => e.finding)];
59105
+ if (introduced.length === 0) {
59106
+ L.push(
59107
+ `\u2705 **This change introduces no agent-security findings.**` + (diff.removed.length ? ` It also fixes ${diff.removed.length}.` : "")
59108
+ );
59109
+ L.push("");
59110
+ } else {
59111
+ L.push(`#### \u26A0\uFE0F Introduced by this change \u2014 ${introduced.length} finding(s)`);
59112
+ L.push("");
59113
+ for (const f of diff.added) findingMd(f, L);
59114
+ for (const e of diff.escalated) {
59115
+ L.push(
59116
+ `> _Guardrail erosion: this finding already existed at **${e.from}** and this change widens it to **${e.to}**._`
59117
+ );
59118
+ L.push("");
59119
+ findingMd(e.finding, L);
59120
+ }
59121
+ }
59122
+ if (diff.removed.length) {
59123
+ L.push(`\u2705 Fixed by this change: ${diff.removed.length} finding(s).`);
59124
+ L.push("");
59125
+ }
59126
+ if (diff.unchanged.length) {
59127
+ L.push(
59128
+ `<details><summary>${diff.unchanged.length} pre-existing finding(s) \u2014 not introduced by this change</summary>`
59129
+ );
59130
+ L.push("");
59131
+ for (const f of diff.unchanged) findingMd(f, L);
59132
+ L.push("</details>");
59133
+ L.push("");
59134
+ }
59135
+ return L.join("\n");
59136
+ }
59137
+ if (diff) {
59138
+ L.push(baseWarning(diff.base));
58932
59139
  L.push("");
58933
59140
  }
59141
+ for (const f of res.findings) findingMd(f, L);
58934
59142
  if (res.findings.length === 0) L.push("No committed agent-security issues found.");
58935
59143
  return L.join("\n");
58936
59144
  }
58937
- function exitCodeFor(res) {
58938
- if (res.worst === "critical" || res.worst === "high") return 2;
58939
- if (res.worst === "medium") return 1;
58940
- if (res.incomplete) return 3;
59145
+ function exitCodeForSeverity(worst, incomplete) {
59146
+ if (worst === "critical" || worst === "high") return 2;
59147
+ if (worst === "medium") return 1;
59148
+ if (incomplete) return 3;
58941
59149
  return 0;
58942
59150
  }
59151
+ function exitCodeFor(res) {
59152
+ return exitCodeForSeverity(res.worst, res.incomplete);
59153
+ }
58943
59154
 
58944
59155
  // src/cli/commands/scan-repo.ts
58945
59156
  var SPIN = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
@@ -58965,23 +59176,36 @@ function makeProgress(target, quiet) {
58965
59176
  };
58966
59177
  }
58967
59178
  function registerScanRepoCommand(program2) {
58968
- program2.command("scan-repo <target>").description("Scan a repo's agent-security surface (GitHub URL or local path)").option("--json", "emit the raw result as JSON").option("--markdown", "emit a Markdown report (for a PR comment)").action(async (target, opts) => {
58969
- const { onProgress, done } = makeProgress(target, !!(opts.json || opts.markdown));
58970
- let res;
58971
- try {
58972
- res = await scanRepo(target, onProgress);
58973
- } finally {
58974
- done();
58975
- }
58976
- if (opts.json) {
58977
- console.log(JSON.stringify(res, null, 2));
58978
- } else if (opts.markdown) {
58979
- console.log(renderScanMarkdown(res));
58980
- } else {
58981
- console.log(renderScan(res));
59179
+ program2.command("scan-repo <target>").description("Scan a repo's agent-security surface (GitHub URL or local path)").option("--json", "emit the raw result as JSON").option("--markdown", "emit a Markdown report (for a PR comment)").option(
59180
+ "--base <ref>",
59181
+ "also scan this git ref and report what the working tree INTRODUCED (local path targets only)"
59182
+ ).option(
59183
+ "--fail-on-introduced",
59184
+ "exit non-zero only for findings this change introduced (requires --base)"
59185
+ ).action(
59186
+ async (target, opts) => {
59187
+ const { onProgress, done } = makeProgress(target, !!(opts.json || opts.markdown));
59188
+ let res;
59189
+ try {
59190
+ res = await scanRepo(target, onProgress);
59191
+ } finally {
59192
+ done();
59193
+ }
59194
+ let diff;
59195
+ if (opts.base) {
59196
+ const baseTree = readGitRefTree(target, opts.base);
59197
+ diff = diffScans(baseTree ? scanTree(baseTree) : null, res);
59198
+ }
59199
+ if (opts.json) {
59200
+ console.log(JSON.stringify(diff ? { ...res, diff } : res, null, 2));
59201
+ } else if (opts.markdown) {
59202
+ console.log(renderScanMarkdown(res, diff));
59203
+ } else {
59204
+ console.log(renderScan(res, diff));
59205
+ }
59206
+ process.exitCode = opts.failOnIntroduced && diff ? exitCodeForSeverity(diff.worstIntroduced, diff.incomplete) : exitCodeFor(res);
58982
59207
  }
58983
- process.exitCode = exitCodeFor(res);
58984
- });
59208
+ );
58985
59209
  }
58986
59210
 
58987
59211
  // src/cli/commands/egress.ts
package/dist/cli.mjs CHANGED
@@ -57928,6 +57928,50 @@ function readLocalTree(dir) {
57928
57928
  }
57929
57929
  return { source: root, files, notes };
57930
57930
  }
57931
+ function readGitRefTree(dir, ref) {
57932
+ const root = dir.replace(/^~/, process.env.HOME ?? "~");
57933
+ if (!ref || ref.startsWith("-")) return null;
57934
+ const git = (args) => {
57935
+ try {
57936
+ return execFileSync2("git", ["-C", root, ...args], {
57937
+ encoding: "utf8",
57938
+ stdio: ["ignore", "pipe", "ignore"],
57939
+ maxBuffer: 32 * 1024 * 1024,
57940
+ timeout: 2e4
57941
+ });
57942
+ } catch {
57943
+ return null;
57944
+ }
57945
+ };
57946
+ const sha = git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`])?.trim();
57947
+ if (!sha) return null;
57948
+ const listing = git(["ls-tree", "-r", "--name-only", "-z", sha]);
57949
+ if (listing === null) return null;
57950
+ const all = listing.split("\0").filter(Boolean);
57951
+ const notes = [];
57952
+ const files = [];
57953
+ const seen = /* @__PURE__ */ new Set();
57954
+ const present = new Set(all);
57955
+ const add = (rel) => {
57956
+ if (seen.has(rel) || !present.has(rel)) return;
57957
+ seen.add(rel);
57958
+ const content = git(["show", `${sha}:${rel}`]);
57959
+ if (content !== null) files.push({ path: rel, content });
57960
+ };
57961
+ for (const rel of SURFACE_FILES) add(rel);
57962
+ const nested = all.filter((rel) => SURFACE_BASENAME.test(rel) && !isIgnoredDir(rel));
57963
+ if (nested.length > MAX_SURFACE_FILES) {
57964
+ notes.push(
57965
+ `repo is large \u2014 some agent-surface files may be INCOMPLETE (capped at ${MAX_SURFACE_FILES} files).`
57966
+ );
57967
+ }
57968
+ for (const rel of nested.slice(0, MAX_SURFACE_FILES)) add(rel);
57969
+ for (const rel of all) {
57970
+ if (rel.startsWith(`${WORKFLOW_DIR}/`) && /\.ya?ml$/.test(rel) && !rel.slice(WORKFLOW_DIR.length + 1).includes("/"))
57971
+ add(rel);
57972
+ }
57973
+ return { source: `${root}@${ref}`, files, notes };
57974
+ }
57931
57975
  async function fetchTree(input, onProgress) {
57932
57976
  if (isLocalPath(input)) return readLocalTree(input);
57933
57977
  const parsed = parseRepoUrl(input);
@@ -58349,6 +58393,8 @@ function analyzeWorkflow(path77, content) {
58349
58393
  const title = severity === "critical" || severity === "high" ? "Injectable agent workflow \u2014 untrusted input reaches a tool-using agent with secrets" : severity === "medium" ? "Agent workflow with a risky pattern (partially mitigated)" : "Agent workflow on a privileged trigger \u2014 review the actor gate";
58350
58394
  return {
58351
58395
  check: "CI-2",
58396
+ // One verdict per workflow file — the finding IS the file's reachability score.
58397
+ rule: "CI-2.injectable-workflow",
58352
58398
  dimension: "workflows",
58353
58399
  severity,
58354
58400
  title,
@@ -58445,6 +58491,7 @@ function analyzeWorkflowSecrets(path77, content) {
58445
58491
  if (!worst) return null;
58446
58492
  return {
58447
58493
  check: "CI-4",
58494
+ rule: "CI-4.agent-reachable-secret",
58448
58495
  dimension: "data",
58449
58496
  severity: worst.severity,
58450
58497
  title: worst.severity === "advisory" ? "Secrets reachable by the agent \u2014 hardening" : "Exfiltratable secrets reachable by an injectable agent",
@@ -58491,6 +58538,10 @@ function analyzeAgentConfig(path77, content) {
58491
58538
  const high = remoteExec || unpinned;
58492
58539
  findings.push({
58493
58540
  check: "CI-1",
58541
+ rule: "CI-1.hook-remote-code",
58542
+ // Identity is the command itself: the same hook keeps its identity when its
58543
+ // severity changes (pinned → unpinned), and two different hooks stay distinct.
58544
+ locator: cmd,
58494
58545
  dimension: "toolRules",
58495
58546
  severity: high ? "high" : "medium",
58496
58547
  title: high ? "Agent hook runs UNPINNED/remote third-party code on every action" : "Agent hook runs third-party code in the agent hot path",
@@ -58511,6 +58562,9 @@ function analyzeAgentConfig(path77, content) {
58511
58562
  const hasBackstop = deny.some((d) => /Bash|Write|Edit/.test(d));
58512
58563
  findings.push({
58513
58564
  check: "CI-1",
58565
+ rule: "CI-1.broad-allow",
58566
+ // File-level: one finding per config file. Adding a SECOND broad allow makes the
58567
+ // same statement about the same file, so it must not read as a new finding.
58514
58568
  dimension: "toolRules",
58515
58569
  severity: hasBackstop ? "medium" : "high",
58516
58570
  title: hasBackstop ? "Committed agent config pre-authorizes broad tools" : "Committed agent config pre-authorizes broad tools with no deny backstop",
@@ -58544,6 +58598,8 @@ function analyzeMcpServers(servers, path77) {
58544
58598
  if (/\bnpx\b/.test(argv) && (/@latest\b/.test(argv) || !/@\d/.test(argv))) {
58545
58599
  findings.push({
58546
58600
  check: "CI-3",
58601
+ rule: "CI-3.mcp-unpinned",
58602
+ locator: name,
58547
58603
  dimension: "mcp",
58548
58604
  severity: "medium",
58549
58605
  title: `MCP server "${name}" runs an unpinned executable`,
@@ -58558,6 +58614,8 @@ function analyzeMcpServers(servers, path77) {
58558
58614
  if (hit) {
58559
58615
  findings.push({
58560
58616
  check: "CI-3",
58617
+ rule: "CI-3.mcp-inline-credential",
58618
+ locator: `${name}.env.${k}`,
58561
58619
  dimension: "mcp",
58562
58620
  severity: "high",
58563
58621
  title: `MCP server "${name}" has an inline credential`,
@@ -58595,6 +58653,10 @@ function analyzeCodexConfig(path77, content) {
58595
58653
  ].filter((s) => s !== null);
58596
58654
  findings.push({
58597
58655
  check: "CI-1",
58656
+ // One finding per Codex config; `danger-full-access` vs `approval_policy = never`
58657
+ // are two severities of the same statement, so tightening one is a de-escalation
58658
+ // of THIS finding rather than the removal of one and the arrival of another.
58659
+ rule: "CI-1.codex-unsafe-defaults",
58598
58660
  dimension: "toolRules",
58599
58661
  severity: fullAccess ? "high" : "medium",
58600
58662
  title: fullAccess ? "Codex config grants a full-access sandbox" : "Codex config never requires approval",
@@ -58654,8 +58716,17 @@ function decodeSuspiciousBase64(text) {
58654
58716
  }
58655
58717
  return out;
58656
58718
  }
58657
- function mk(severity, title, signals, fix, path77) {
58658
- return { check: "CI-6", dimension: "instructions", severity, title, file: path77, signals, fix };
58719
+ function mk(rule, severity, title, signals, fix, path77) {
58720
+ return {
58721
+ check: "CI-6",
58722
+ rule,
58723
+ dimension: "instructions",
58724
+ severity,
58725
+ title,
58726
+ file: path77,
58727
+ signals,
58728
+ fix
58729
+ };
58659
58730
  }
58660
58731
  function analyzeInstructionFile(path77, content) {
58661
58732
  const findings = [];
@@ -58663,6 +58734,7 @@ function analyzeInstructionFile(path77, content) {
58663
58734
  if (TAG_CHARS.test(content))
58664
58735
  findings.push(
58665
58736
  mk(
58737
+ "CI-6.unicode-tag-chars",
58666
58738
  "critical",
58667
58739
  "Unicode tag characters in an agent instruction file",
58668
58740
  [
@@ -58675,6 +58747,7 @@ function analyzeInstructionFile(path77, content) {
58675
58747
  if (BIDI_OVERRIDE.test(content))
58676
58748
  findings.push(
58677
58749
  mk(
58750
+ "CI-6.bidi-override",
58678
58751
  "critical",
58679
58752
  "Bidirectional override characters in an agent instruction file",
58680
58753
  [
@@ -58687,6 +58760,7 @@ function analyzeInstructionFile(path77, content) {
58687
58760
  else if (BIDI_EMBED_ISOLATE.test(content))
58688
58761
  findings.push(
58689
58762
  mk(
58763
+ "CI-6.bidi-formatting",
58690
58764
  "advisory",
58691
58765
  "Bidirectional formatting characters in an agent instruction file",
58692
58766
  [
@@ -58701,6 +58775,7 @@ function analyzeInstructionFile(path77, content) {
58701
58775
  const revealed = OVERRIDE_RE.test(stripZeroWidth(content)) && !OVERRIDE_RE.test(content);
58702
58776
  findings.push(
58703
58777
  mk(
58778
+ "CI-6.zero-width",
58704
58779
  revealed ? "critical" : "medium",
58705
58780
  "Zero-width characters splitting text in an agent instruction file",
58706
58781
  [
@@ -58717,6 +58792,7 @@ function analyzeInstructionFile(path77, content) {
58717
58792
  const m = ov || ovEnc;
58718
58793
  findings.push(
58719
58794
  mk(
58795
+ "CI-6.prompt-override",
58720
58796
  ovEnc ? "critical" : "high",
58721
58797
  "Prompt-override directive in an agent instruction file",
58722
58798
  [
@@ -58731,6 +58807,7 @@ function analyzeInstructionFile(path77, content) {
58731
58807
  if (fo && !inHumanSection(content, fo.index) && !isNegated(content, fo.index)) {
58732
58808
  findings.push(
58733
58809
  mk(
58810
+ "CI-6.fetch-and-obey",
58734
58811
  "medium",
58735
58812
  "Instruction directs the agent to fetch and run remote code",
58736
58813
  [`\`${fo[0].slice(0, 70).trim()}\` \u2014 fetch-and-obey, outside an install/setup section`],
@@ -58743,6 +58820,7 @@ function analyzeInstructionFile(path77, content) {
58743
58820
  if (sp && !isNegated(content, sp.index)) {
58744
58821
  findings.push(
58745
58822
  mk(
58823
+ "CI-6.secret-path",
58746
58824
  "medium",
58747
58825
  "Instruction points the agent at credential material",
58748
58826
  [`references \`${sp[0].slice(0, 50).trim()}\` \u2014 directs the agent toward secrets`],
@@ -58755,6 +58833,7 @@ function analyzeInstructionFile(path77, content) {
58755
58833
  if (ex && !inHumanSection(content, ex.index) && !isNegated(content, ex.index)) {
58756
58834
  findings.push(
58757
58835
  mk(
58836
+ "CI-6.exfil-directive",
58758
58837
  "medium",
58759
58838
  "Instruction directs the agent to send data to an external endpoint",
58760
58839
  [`\`${ex[0].slice(0, 70).trim()}\` \u2014 possible exfiltration directive`],
@@ -58766,8 +58845,82 @@ function analyzeInstructionFile(path77, content) {
58766
58845
  return findings;
58767
58846
  }
58768
58847
 
58848
+ // src/ci-check/diff.ts
58849
+ import { createHash as createHash4 } from "crypto";
58850
+ function fingerprintOf(f) {
58851
+ const key = JSON.stringify([f.rule, f.file, f.locator ?? "", f.ordinal ?? 0]);
58852
+ return createHash4("sha256").update(key).digest("hex").slice(0, 16);
58853
+ }
58854
+ function assignOrdinals(findings) {
58855
+ const seen = /* @__PURE__ */ new Map();
58856
+ for (const f of findings) {
58857
+ const key = JSON.stringify([f.rule, f.file, f.locator ?? ""]);
58858
+ const n = seen.get(key) ?? 0;
58859
+ if (n > 0) f.ordinal = n;
58860
+ seen.set(key, n + 1);
58861
+ }
58862
+ return findings;
58863
+ }
58864
+ function worstOf(severities) {
58865
+ let worst = null;
58866
+ for (const s of severities) {
58867
+ if (!worst || SEVERITY_RANK2[s] > SEVERITY_RANK2[worst]) worst = s;
58868
+ }
58869
+ return worst;
58870
+ }
58871
+ function baseStateOf(base) {
58872
+ if (!base) return "did-not-run";
58873
+ return base.incomplete ? "incomplete" : "ok";
58874
+ }
58875
+ function diffScans(base, head) {
58876
+ const state = baseStateOf(base);
58877
+ if (state !== "ok" || !base) {
58878
+ return {
58879
+ base: state,
58880
+ added: [],
58881
+ removed: [],
58882
+ unchanged: [...head.findings],
58883
+ escalated: [],
58884
+ worstIntroduced: head.worst,
58885
+ incomplete: true
58886
+ };
58887
+ }
58888
+ const baseByFp = /* @__PURE__ */ new Map();
58889
+ for (const f of base.findings) baseByFp.set(fingerprintOf(f), f);
58890
+ const added = [];
58891
+ const unchanged = [];
58892
+ const escalated = [];
58893
+ const matched = /* @__PURE__ */ new Set();
58894
+ for (const f of head.findings) {
58895
+ const fp = fingerprintOf(f);
58896
+ const prior = baseByFp.get(fp);
58897
+ if (!prior) {
58898
+ added.push(f);
58899
+ continue;
58900
+ }
58901
+ matched.add(fp);
58902
+ if (SEVERITY_RANK2[f.severity] > SEVERITY_RANK2[prior.severity]) {
58903
+ escalated.push({ finding: f, from: prior.severity, to: f.severity });
58904
+ } else {
58905
+ unchanged.push(f);
58906
+ }
58907
+ }
58908
+ const removed = base.findings.filter((f) => !matched.has(fingerprintOf(f)));
58909
+ return {
58910
+ base: state,
58911
+ added,
58912
+ removed,
58913
+ unchanged,
58914
+ escalated,
58915
+ worstIntroduced: worstOf([...added.map((f) => f.severity), ...escalated.map((e) => e.to)]),
58916
+ // The head side of the same guard: a scan that could not read every file has not
58917
+ // earned the word "clean", however trustworthy the base was.
58918
+ incomplete: head.incomplete
58919
+ };
58920
+ }
58921
+
58769
58922
  // src/ci-check/index.ts
58770
- function worstOf(findings) {
58923
+ function worstOf2(findings) {
58771
58924
  let worst = null;
58772
58925
  for (const f of findings) {
58773
58926
  if (!worst || SEVERITY_RANK2[f.severity] > SEVERITY_RANK2[worst]) worst = f.severity;
@@ -58801,11 +58954,12 @@ function scanTree(tree) {
58801
58954
  notes.push(`checker degraded on ${file.path}: ${err2?.message ?? "error"}`);
58802
58955
  }
58803
58956
  }
58957
+ assignOrdinals(findings);
58804
58958
  findings.sort(
58805
58959
  (a, b) => SEVERITY_RANK2[b.severity] - SEVERITY_RANK2[a.severity] || a.file.localeCompare(b.file)
58806
58960
  );
58807
58961
  const incomplete = notes.some((nt) => /may be INCOMPLETE/i.test(nt));
58808
- return { source: tree.source, findings, inspected, notes, worst: worstOf(findings), incomplete };
58962
+ return { source: tree.source, findings, inspected, notes, worst: worstOf2(findings), incomplete };
58809
58963
  }
58810
58964
  async function scanRepo(input, onProgress) {
58811
58965
  const tree = await fetchTree(input, onProgress);
@@ -58859,12 +59013,37 @@ function renderCta(res) {
58859
59013
  function ownedHint(source) {
58860
59014
  return source.startsWith("/") || source.startsWith(".") || source.startsWith("~");
58861
59015
  }
58862
- function renderScan(res) {
59016
+ function findingMd(f, L) {
59017
+ L.push(`**${ICON2[f.severity]} ${f.severity.toUpperCase()} \u2014 ${f.title}**`);
59018
+ L.push(`\`${f.file}${f.line ? ":" + f.line : ""}\` \xB7 ${f.rule}`);
59019
+ L.push("");
59020
+ for (const s of f.signals) L.push(`- ${s}`);
59021
+ if (f.mitigations?.length) L.push(`- _mitigated:_ ${f.mitigations.join("; ")}`);
59022
+ L.push("");
59023
+ L.push(`\u2192 **Fix:** ${f.fix}`);
59024
+ L.push("");
59025
+ }
59026
+ function baseWarning(base) {
59027
+ return base === "did-not-run" ? '\u26A0\uFE0F **Could not read the base commit**, so nothing below can be called "new" \u2014 every finding in this repo is listed. (A shallow clone is the usual cause: fetch the base ref.)' : "\u26A0\uFE0F **The base scan could not read every file**, so a finding missing from it would look new. Every finding in this repo is listed instead.";
59028
+ }
59029
+ function renderScan(res, diff) {
58863
59030
  const L = [];
58864
59031
  const n = res.findings.length;
58865
59032
  const head = res.worst === "critical" || res.worst === "high" ? chalk32.red.bold("\u26A0\uFE0F agent-security risk found") : res.worst ? chalk32.yellow("agent-security notes") : res.incomplete ? chalk32.yellow.bold("\u26A0\uFE0F INCOMPLETE \u2014 could not read all files") : chalk32.green("\u2705 agent-security: clean");
58866
59033
  L.push(`\u{1F6E1}\uFE0F ${chalk32.bold("node9 scan-repo")} \xB7 ${res.source} \xB7 ${head}`);
58867
59034
  L.push(chalk32.gray(` inspected ${res.inspected.length} config file(s), ${n} finding(s)`));
59035
+ if (diff) {
59036
+ const introduced = diff.added.length + diff.escalated.length;
59037
+ L.push(
59038
+ diff.base !== "ok" ? chalk32.yellow.bold(
59039
+ ` \u26A0\uFE0F base ${diff.base === "did-not-run" ? "could not be read" : "scan was incomplete"} \u2014 cannot say what is new; showing everything`
59040
+ ) : introduced > 0 ? chalk32.red.bold(
59041
+ ` \u26A0\uFE0F this change introduced ${introduced} finding(s)` + (diff.escalated.length ? ` (${diff.escalated.length} by widening an existing one)` : "")
59042
+ ) : chalk32.green(
59043
+ ` \u2705 this change introduced nothing` + (diff.unchanged.length ? ` (${diff.unchanged.length} pre-existing)` : "") + (diff.removed.length ? `, and fixed ${diff.removed.length}` : "")
59044
+ )
59045
+ );
59046
+ }
58868
59047
  if (res.incomplete) {
58869
59048
  const rateLimited = res.notes.some((nt) => /rate limit/i.test(nt));
58870
59049
  L.push(
@@ -58904,7 +59083,7 @@ function renderScan(res) {
58904
59083
  L.push(...renderCta(res));
58905
59084
  return L.join("\n");
58906
59085
  }
58907
- function renderScanMarkdown(res) {
59086
+ function renderScanMarkdown(res, diff) {
58908
59087
  const L = [];
58909
59088
  const status = res.worst === "critical" || res.worst === "high" ? "\u26A0\uFE0F" : res.worst ? "\u{1F7E1}" : res.incomplete ? "\u26A0\uFE0F" : "\u2705";
58910
59089
  L.push(`### \u{1F6E1}\uFE0F node9 agent-security \xB7 \`${res.source}\` \xB7 ${status}`);
@@ -58913,25 +59092,57 @@ function renderScanMarkdown(res) {
58913
59092
  `Inspected ${res.inspected.length} config file(s) \xB7 **${res.findings.length} finding(s)**`
58914
59093
  );
58915
59094
  L.push("");
58916
- for (const f of res.findings) {
58917
- L.push(`**${ICON2[f.severity]} ${f.severity.toUpperCase()} \u2014 ${f.title}**`);
58918
- L.push(`\`${f.file}${f.line ? ":" + f.line : ""}\` \xB7 ${f.check}`);
58919
- L.push("");
58920
- for (const s of f.signals) L.push(`- ${s}`);
58921
- if (f.mitigations?.length) L.push(`- _mitigated:_ ${f.mitigations.join("; ")}`);
58922
- L.push("");
58923
- L.push(`\u2192 **Fix:** ${f.fix}`);
59095
+ if (diff && diff.base === "ok") {
59096
+ const introduced = [...diff.added, ...diff.escalated.map((e) => e.finding)];
59097
+ if (introduced.length === 0) {
59098
+ L.push(
59099
+ `\u2705 **This change introduces no agent-security findings.**` + (diff.removed.length ? ` It also fixes ${diff.removed.length}.` : "")
59100
+ );
59101
+ L.push("");
59102
+ } else {
59103
+ L.push(`#### \u26A0\uFE0F Introduced by this change \u2014 ${introduced.length} finding(s)`);
59104
+ L.push("");
59105
+ for (const f of diff.added) findingMd(f, L);
59106
+ for (const e of diff.escalated) {
59107
+ L.push(
59108
+ `> _Guardrail erosion: this finding already existed at **${e.from}** and this change widens it to **${e.to}**._`
59109
+ );
59110
+ L.push("");
59111
+ findingMd(e.finding, L);
59112
+ }
59113
+ }
59114
+ if (diff.removed.length) {
59115
+ L.push(`\u2705 Fixed by this change: ${diff.removed.length} finding(s).`);
59116
+ L.push("");
59117
+ }
59118
+ if (diff.unchanged.length) {
59119
+ L.push(
59120
+ `<details><summary>${diff.unchanged.length} pre-existing finding(s) \u2014 not introduced by this change</summary>`
59121
+ );
59122
+ L.push("");
59123
+ for (const f of diff.unchanged) findingMd(f, L);
59124
+ L.push("</details>");
59125
+ L.push("");
59126
+ }
59127
+ return L.join("\n");
59128
+ }
59129
+ if (diff) {
59130
+ L.push(baseWarning(diff.base));
58924
59131
  L.push("");
58925
59132
  }
59133
+ for (const f of res.findings) findingMd(f, L);
58926
59134
  if (res.findings.length === 0) L.push("No committed agent-security issues found.");
58927
59135
  return L.join("\n");
58928
59136
  }
58929
- function exitCodeFor(res) {
58930
- if (res.worst === "critical" || res.worst === "high") return 2;
58931
- if (res.worst === "medium") return 1;
58932
- if (res.incomplete) return 3;
59137
+ function exitCodeForSeverity(worst, incomplete) {
59138
+ if (worst === "critical" || worst === "high") return 2;
59139
+ if (worst === "medium") return 1;
59140
+ if (incomplete) return 3;
58933
59141
  return 0;
58934
59142
  }
59143
+ function exitCodeFor(res) {
59144
+ return exitCodeForSeverity(res.worst, res.incomplete);
59145
+ }
58935
59146
 
58936
59147
  // src/cli/commands/scan-repo.ts
58937
59148
  var SPIN = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
@@ -58957,23 +59168,36 @@ function makeProgress(target, quiet) {
58957
59168
  };
58958
59169
  }
58959
59170
  function registerScanRepoCommand(program2) {
58960
- program2.command("scan-repo <target>").description("Scan a repo's agent-security surface (GitHub URL or local path)").option("--json", "emit the raw result as JSON").option("--markdown", "emit a Markdown report (for a PR comment)").action(async (target, opts) => {
58961
- const { onProgress, done } = makeProgress(target, !!(opts.json || opts.markdown));
58962
- let res;
58963
- try {
58964
- res = await scanRepo(target, onProgress);
58965
- } finally {
58966
- done();
58967
- }
58968
- if (opts.json) {
58969
- console.log(JSON.stringify(res, null, 2));
58970
- } else if (opts.markdown) {
58971
- console.log(renderScanMarkdown(res));
58972
- } else {
58973
- console.log(renderScan(res));
59171
+ program2.command("scan-repo <target>").description("Scan a repo's agent-security surface (GitHub URL or local path)").option("--json", "emit the raw result as JSON").option("--markdown", "emit a Markdown report (for a PR comment)").option(
59172
+ "--base <ref>",
59173
+ "also scan this git ref and report what the working tree INTRODUCED (local path targets only)"
59174
+ ).option(
59175
+ "--fail-on-introduced",
59176
+ "exit non-zero only for findings this change introduced (requires --base)"
59177
+ ).action(
59178
+ async (target, opts) => {
59179
+ const { onProgress, done } = makeProgress(target, !!(opts.json || opts.markdown));
59180
+ let res;
59181
+ try {
59182
+ res = await scanRepo(target, onProgress);
59183
+ } finally {
59184
+ done();
59185
+ }
59186
+ let diff;
59187
+ if (opts.base) {
59188
+ const baseTree = readGitRefTree(target, opts.base);
59189
+ diff = diffScans(baseTree ? scanTree(baseTree) : null, res);
59190
+ }
59191
+ if (opts.json) {
59192
+ console.log(JSON.stringify(diff ? { ...res, diff } : res, null, 2));
59193
+ } else if (opts.markdown) {
59194
+ console.log(renderScanMarkdown(res, diff));
59195
+ } else {
59196
+ console.log(renderScan(res, diff));
59197
+ }
59198
+ process.exitCode = opts.failOnIntroduced && diff ? exitCodeForSeverity(diff.worstIntroduced, diff.incomplete) : exitCodeFor(res);
58974
59199
  }
58975
- process.exitCode = exitCodeFor(res);
58976
- });
59200
+ );
58977
59201
  }
58978
59202
 
58979
59203
  // src/cli/commands/egress.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/proxy",
3
- "version": "2.10.0",
3
+ "version": "2.12.0",
4
4
  "description": "The Sudo Command for AI Agents. Execution Security for Claude Code, Codex, Gemini, Cursor, Opencode, Pi, and any MCP server.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -16,7 +16,7 @@
16
16
  "node9": "./dist/cli.js"
17
17
  },
18
18
  "engines": {
19
- "node": ">=18"
19
+ "node": ">=20"
20
20
  },
21
21
  "workspaces": [
22
22
  "packages/*"