@node9/proxy 2.11.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -20607,13 +20607,36 @@ function isPolicyStale(nowMs = Date.now(), health) {
20607
20607
  if (Number.isNaN(last)) return false;
20608
20608
  return nowMs - last > stalenessThresholdMs(effectiveSyncIntervalMs());
20609
20609
  }
20610
- function fetchCloudPolicy(apiKey, apiUrl, ifNoneMatch) {
20611
- const parsed = new URL(apiUrl);
20610
+ function safeNode9Version() {
20611
+ let dir = __dirname;
20612
+ for (let up = 0; up < 5; up++) {
20613
+ try {
20614
+ const pkg = JSON.parse(import_fs38.default.readFileSync(import_path37.default.join(dir, "package.json"), "utf-8"));
20615
+ if (pkg.name === "@node9/proxy" || pkg.name === "node9-ai") {
20616
+ return pkg.version;
20617
+ }
20618
+ } catch {
20619
+ }
20620
+ const parent = import_path37.default.dirname(dir);
20621
+ if (parent === dir) break;
20622
+ dir = parent;
20623
+ }
20624
+ return void 0;
20625
+ }
20626
+ function buildPolicyPullHeaders(apiKey, ifNoneMatch, proxyVersion) {
20612
20627
  const headers = {
20613
20628
  Authorization: `Bearer ${apiKey}`,
20614
20629
  "Content-Type": "application/json"
20615
20630
  };
20616
20631
  if (ifNoneMatch) headers["If-None-Match"] = `"${ifNoneMatch}"`;
20632
+ if (proxyVersion && proxyVersion !== "unknown") {
20633
+ headers["X-Node9-Version"] = proxyVersion;
20634
+ }
20635
+ return headers;
20636
+ }
20637
+ function fetchCloudPolicy(apiKey, apiUrl, ifNoneMatch) {
20638
+ const parsed = new URL(apiUrl);
20639
+ const headers = buildPolicyPullHeaders(apiKey, ifNoneMatch, safeNode9Version());
20617
20640
  return new Promise((resolve2, reject) => {
20618
20641
  const req = import_https4.default.request(
20619
20642
  {
@@ -57936,6 +57959,50 @@ function readLocalTree(dir) {
57936
57959
  }
57937
57960
  return { source: root, files, notes };
57938
57961
  }
57962
+ function readGitRefTree(dir, ref) {
57963
+ const root = dir.replace(/^~/, process.env.HOME ?? "~");
57964
+ if (!ref || ref.startsWith("-")) return null;
57965
+ const git = (args) => {
57966
+ try {
57967
+ return (0, import_node_child_process.execFileSync)("git", ["-C", root, ...args], {
57968
+ encoding: "utf8",
57969
+ stdio: ["ignore", "pipe", "ignore"],
57970
+ maxBuffer: 32 * 1024 * 1024,
57971
+ timeout: 2e4
57972
+ });
57973
+ } catch {
57974
+ return null;
57975
+ }
57976
+ };
57977
+ const sha = git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`])?.trim();
57978
+ if (!sha) return null;
57979
+ const listing = git(["ls-tree", "-r", "--name-only", "-z", sha]);
57980
+ if (listing === null) return null;
57981
+ const all = listing.split("\0").filter(Boolean);
57982
+ const notes = [];
57983
+ const files = [];
57984
+ const seen = /* @__PURE__ */ new Set();
57985
+ const present = new Set(all);
57986
+ const add = (rel) => {
57987
+ if (seen.has(rel) || !present.has(rel)) return;
57988
+ seen.add(rel);
57989
+ const content = git(["show", `${sha}:${rel}`]);
57990
+ if (content !== null) files.push({ path: rel, content });
57991
+ };
57992
+ for (const rel of SURFACE_FILES) add(rel);
57993
+ const nested = all.filter((rel) => SURFACE_BASENAME.test(rel) && !isIgnoredDir(rel));
57994
+ if (nested.length > MAX_SURFACE_FILES) {
57995
+ notes.push(
57996
+ `repo is large \u2014 some agent-surface files may be INCOMPLETE (capped at ${MAX_SURFACE_FILES} files).`
57997
+ );
57998
+ }
57999
+ for (const rel of nested.slice(0, MAX_SURFACE_FILES)) add(rel);
58000
+ for (const rel of all) {
58001
+ if (rel.startsWith(`${WORKFLOW_DIR}/`) && /\.ya?ml$/.test(rel) && !rel.slice(WORKFLOW_DIR.length + 1).includes("/"))
58002
+ add(rel);
58003
+ }
58004
+ return { source: `${root}@${ref}`, files, notes };
58005
+ }
57939
58006
  async function fetchTree(input, onProgress) {
57940
58007
  if (isLocalPath(input)) return readLocalTree(input);
57941
58008
  const parsed = parseRepoUrl(input);
@@ -58357,6 +58424,8 @@ function analyzeWorkflow(path77, content) {
58357
58424
  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
58425
  return {
58359
58426
  check: "CI-2",
58427
+ // One verdict per workflow file — the finding IS the file's reachability score.
58428
+ rule: "CI-2.injectable-workflow",
58360
58429
  dimension: "workflows",
58361
58430
  severity,
58362
58431
  title,
@@ -58453,6 +58522,7 @@ function analyzeWorkflowSecrets(path77, content) {
58453
58522
  if (!worst) return null;
58454
58523
  return {
58455
58524
  check: "CI-4",
58525
+ rule: "CI-4.agent-reachable-secret",
58456
58526
  dimension: "data",
58457
58527
  severity: worst.severity,
58458
58528
  title: worst.severity === "advisory" ? "Secrets reachable by the agent \u2014 hardening" : "Exfiltratable secrets reachable by an injectable agent",
@@ -58499,6 +58569,10 @@ function analyzeAgentConfig(path77, content) {
58499
58569
  const high = remoteExec || unpinned;
58500
58570
  findings.push({
58501
58571
  check: "CI-1",
58572
+ rule: "CI-1.hook-remote-code",
58573
+ // Identity is the command itself: the same hook keeps its identity when its
58574
+ // severity changes (pinned → unpinned), and two different hooks stay distinct.
58575
+ locator: cmd,
58502
58576
  dimension: "toolRules",
58503
58577
  severity: high ? "high" : "medium",
58504
58578
  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 +58593,9 @@ function analyzeAgentConfig(path77, content) {
58519
58593
  const hasBackstop = deny.some((d) => /Bash|Write|Edit/.test(d));
58520
58594
  findings.push({
58521
58595
  check: "CI-1",
58596
+ rule: "CI-1.broad-allow",
58597
+ // File-level: one finding per config file. Adding a SECOND broad allow makes the
58598
+ // same statement about the same file, so it must not read as a new finding.
58522
58599
  dimension: "toolRules",
58523
58600
  severity: hasBackstop ? "medium" : "high",
58524
58601
  title: hasBackstop ? "Committed agent config pre-authorizes broad tools" : "Committed agent config pre-authorizes broad tools with no deny backstop",
@@ -58552,6 +58629,8 @@ function analyzeMcpServers(servers, path77) {
58552
58629
  if (/\bnpx\b/.test(argv) && (/@latest\b/.test(argv) || !/@\d/.test(argv))) {
58553
58630
  findings.push({
58554
58631
  check: "CI-3",
58632
+ rule: "CI-3.mcp-unpinned",
58633
+ locator: name,
58555
58634
  dimension: "mcp",
58556
58635
  severity: "medium",
58557
58636
  title: `MCP server "${name}" runs an unpinned executable`,
@@ -58566,6 +58645,8 @@ function analyzeMcpServers(servers, path77) {
58566
58645
  if (hit) {
58567
58646
  findings.push({
58568
58647
  check: "CI-3",
58648
+ rule: "CI-3.mcp-inline-credential",
58649
+ locator: `${name}.env.${k}`,
58569
58650
  dimension: "mcp",
58570
58651
  severity: "high",
58571
58652
  title: `MCP server "${name}" has an inline credential`,
@@ -58603,6 +58684,10 @@ function analyzeCodexConfig(path77, content) {
58603
58684
  ].filter((s) => s !== null);
58604
58685
  findings.push({
58605
58686
  check: "CI-1",
58687
+ // One finding per Codex config; `danger-full-access` vs `approval_policy = never`
58688
+ // are two severities of the same statement, so tightening one is a de-escalation
58689
+ // of THIS finding rather than the removal of one and the arrival of another.
58690
+ rule: "CI-1.codex-unsafe-defaults",
58606
58691
  dimension: "toolRules",
58607
58692
  severity: fullAccess ? "high" : "medium",
58608
58693
  title: fullAccess ? "Codex config grants a full-access sandbox" : "Codex config never requires approval",
@@ -58662,8 +58747,17 @@ function decodeSuspiciousBase64(text) {
58662
58747
  }
58663
58748
  return out;
58664
58749
  }
58665
- function mk(severity, title, signals, fix, path77) {
58666
- return { check: "CI-6", dimension: "instructions", severity, title, file: path77, signals, fix };
58750
+ function mk(rule, severity, title, signals, fix, path77) {
58751
+ return {
58752
+ check: "CI-6",
58753
+ rule,
58754
+ dimension: "instructions",
58755
+ severity,
58756
+ title,
58757
+ file: path77,
58758
+ signals,
58759
+ fix
58760
+ };
58667
58761
  }
58668
58762
  function analyzeInstructionFile(path77, content) {
58669
58763
  const findings = [];
@@ -58671,6 +58765,7 @@ function analyzeInstructionFile(path77, content) {
58671
58765
  if (TAG_CHARS.test(content))
58672
58766
  findings.push(
58673
58767
  mk(
58768
+ "CI-6.unicode-tag-chars",
58674
58769
  "critical",
58675
58770
  "Unicode tag characters in an agent instruction file",
58676
58771
  [
@@ -58683,6 +58778,7 @@ function analyzeInstructionFile(path77, content) {
58683
58778
  if (BIDI_OVERRIDE.test(content))
58684
58779
  findings.push(
58685
58780
  mk(
58781
+ "CI-6.bidi-override",
58686
58782
  "critical",
58687
58783
  "Bidirectional override characters in an agent instruction file",
58688
58784
  [
@@ -58695,6 +58791,7 @@ function analyzeInstructionFile(path77, content) {
58695
58791
  else if (BIDI_EMBED_ISOLATE.test(content))
58696
58792
  findings.push(
58697
58793
  mk(
58794
+ "CI-6.bidi-formatting",
58698
58795
  "advisory",
58699
58796
  "Bidirectional formatting characters in an agent instruction file",
58700
58797
  [
@@ -58709,6 +58806,7 @@ function analyzeInstructionFile(path77, content) {
58709
58806
  const revealed = OVERRIDE_RE.test(stripZeroWidth(content)) && !OVERRIDE_RE.test(content);
58710
58807
  findings.push(
58711
58808
  mk(
58809
+ "CI-6.zero-width",
58712
58810
  revealed ? "critical" : "medium",
58713
58811
  "Zero-width characters splitting text in an agent instruction file",
58714
58812
  [
@@ -58725,6 +58823,7 @@ function analyzeInstructionFile(path77, content) {
58725
58823
  const m = ov || ovEnc;
58726
58824
  findings.push(
58727
58825
  mk(
58826
+ "CI-6.prompt-override",
58728
58827
  ovEnc ? "critical" : "high",
58729
58828
  "Prompt-override directive in an agent instruction file",
58730
58829
  [
@@ -58739,6 +58838,7 @@ function analyzeInstructionFile(path77, content) {
58739
58838
  if (fo && !inHumanSection(content, fo.index) && !isNegated(content, fo.index)) {
58740
58839
  findings.push(
58741
58840
  mk(
58841
+ "CI-6.fetch-and-obey",
58742
58842
  "medium",
58743
58843
  "Instruction directs the agent to fetch and run remote code",
58744
58844
  [`\`${fo[0].slice(0, 70).trim()}\` \u2014 fetch-and-obey, outside an install/setup section`],
@@ -58751,6 +58851,7 @@ function analyzeInstructionFile(path77, content) {
58751
58851
  if (sp && !isNegated(content, sp.index)) {
58752
58852
  findings.push(
58753
58853
  mk(
58854
+ "CI-6.secret-path",
58754
58855
  "medium",
58755
58856
  "Instruction points the agent at credential material",
58756
58857
  [`references \`${sp[0].slice(0, 50).trim()}\` \u2014 directs the agent toward secrets`],
@@ -58763,6 +58864,7 @@ function analyzeInstructionFile(path77, content) {
58763
58864
  if (ex && !inHumanSection(content, ex.index) && !isNegated(content, ex.index)) {
58764
58865
  findings.push(
58765
58866
  mk(
58867
+ "CI-6.exfil-directive",
58766
58868
  "medium",
58767
58869
  "Instruction directs the agent to send data to an external endpoint",
58768
58870
  [`\`${ex[0].slice(0, 70).trim()}\` \u2014 possible exfiltration directive`],
@@ -58774,8 +58876,82 @@ function analyzeInstructionFile(path77, content) {
58774
58876
  return findings;
58775
58877
  }
58776
58878
 
58879
+ // src/ci-check/diff.ts
58880
+ var import_node_crypto = require("crypto");
58881
+ function fingerprintOf(f) {
58882
+ const key = JSON.stringify([f.rule, f.file, f.locator ?? "", f.ordinal ?? 0]);
58883
+ return (0, import_node_crypto.createHash)("sha256").update(key).digest("hex").slice(0, 16);
58884
+ }
58885
+ function assignOrdinals(findings) {
58886
+ const seen = /* @__PURE__ */ new Map();
58887
+ for (const f of findings) {
58888
+ const key = JSON.stringify([f.rule, f.file, f.locator ?? ""]);
58889
+ const n = seen.get(key) ?? 0;
58890
+ if (n > 0) f.ordinal = n;
58891
+ seen.set(key, n + 1);
58892
+ }
58893
+ return findings;
58894
+ }
58895
+ function worstOf(severities) {
58896
+ let worst = null;
58897
+ for (const s of severities) {
58898
+ if (!worst || SEVERITY_RANK2[s] > SEVERITY_RANK2[worst]) worst = s;
58899
+ }
58900
+ return worst;
58901
+ }
58902
+ function baseStateOf(base) {
58903
+ if (!base) return "did-not-run";
58904
+ return base.incomplete ? "incomplete" : "ok";
58905
+ }
58906
+ function diffScans(base, head) {
58907
+ const state = baseStateOf(base);
58908
+ if (state !== "ok" || !base) {
58909
+ return {
58910
+ base: state,
58911
+ added: [],
58912
+ removed: [],
58913
+ unchanged: [...head.findings],
58914
+ escalated: [],
58915
+ worstIntroduced: head.worst,
58916
+ incomplete: true
58917
+ };
58918
+ }
58919
+ const baseByFp = /* @__PURE__ */ new Map();
58920
+ for (const f of base.findings) baseByFp.set(fingerprintOf(f), f);
58921
+ const added = [];
58922
+ const unchanged = [];
58923
+ const escalated = [];
58924
+ const matched = /* @__PURE__ */ new Set();
58925
+ for (const f of head.findings) {
58926
+ const fp = fingerprintOf(f);
58927
+ const prior = baseByFp.get(fp);
58928
+ if (!prior) {
58929
+ added.push(f);
58930
+ continue;
58931
+ }
58932
+ matched.add(fp);
58933
+ if (SEVERITY_RANK2[f.severity] > SEVERITY_RANK2[prior.severity]) {
58934
+ escalated.push({ finding: f, from: prior.severity, to: f.severity });
58935
+ } else {
58936
+ unchanged.push(f);
58937
+ }
58938
+ }
58939
+ const removed = base.findings.filter((f) => !matched.has(fingerprintOf(f)));
58940
+ return {
58941
+ base: state,
58942
+ added,
58943
+ removed,
58944
+ unchanged,
58945
+ escalated,
58946
+ worstIntroduced: worstOf([...added.map((f) => f.severity), ...escalated.map((e) => e.to)]),
58947
+ // The head side of the same guard: a scan that could not read every file has not
58948
+ // earned the word "clean", however trustworthy the base was.
58949
+ incomplete: head.incomplete
58950
+ };
58951
+ }
58952
+
58777
58953
  // src/ci-check/index.ts
58778
- function worstOf(findings) {
58954
+ function worstOf2(findings) {
58779
58955
  let worst = null;
58780
58956
  for (const f of findings) {
58781
58957
  if (!worst || SEVERITY_RANK2[f.severity] > SEVERITY_RANK2[worst]) worst = f.severity;
@@ -58809,11 +58985,12 @@ function scanTree(tree) {
58809
58985
  notes.push(`checker degraded on ${file.path}: ${err2?.message ?? "error"}`);
58810
58986
  }
58811
58987
  }
58988
+ assignOrdinals(findings);
58812
58989
  findings.sort(
58813
58990
  (a, b) => SEVERITY_RANK2[b.severity] - SEVERITY_RANK2[a.severity] || a.file.localeCompare(b.file)
58814
58991
  );
58815
58992
  const incomplete = notes.some((nt) => /may be INCOMPLETE/i.test(nt));
58816
- return { source: tree.source, findings, inspected, notes, worst: worstOf(findings), incomplete };
58993
+ return { source: tree.source, findings, inspected, notes, worst: worstOf2(findings), incomplete };
58817
58994
  }
58818
58995
  async function scanRepo(input, onProgress) {
58819
58996
  const tree = await fetchTree(input, onProgress);
@@ -58867,12 +59044,37 @@ function renderCta(res) {
58867
59044
  function ownedHint(source) {
58868
59045
  return source.startsWith("/") || source.startsWith(".") || source.startsWith("~");
58869
59046
  }
58870
- function renderScan(res) {
59047
+ function findingMd(f, L) {
59048
+ L.push(`**${ICON2[f.severity]} ${f.severity.toUpperCase()} \u2014 ${f.title}**`);
59049
+ L.push(`\`${f.file}${f.line ? ":" + f.line : ""}\` \xB7 ${f.rule}`);
59050
+ L.push("");
59051
+ for (const s of f.signals) L.push(`- ${s}`);
59052
+ if (f.mitigations?.length) L.push(`- _mitigated:_ ${f.mitigations.join("; ")}`);
59053
+ L.push("");
59054
+ L.push(`\u2192 **Fix:** ${f.fix}`);
59055
+ L.push("");
59056
+ }
59057
+ function baseWarning(base) {
59058
+ 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.";
59059
+ }
59060
+ function renderScan(res, diff) {
58871
59061
  const L = [];
58872
59062
  const n = res.findings.length;
58873
59063
  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
59064
  L.push(`\u{1F6E1}\uFE0F ${import_chalk32.default.bold("node9 scan-repo")} \xB7 ${res.source} \xB7 ${head}`);
58875
59065
  L.push(import_chalk32.default.gray(` inspected ${res.inspected.length} config file(s), ${n} finding(s)`));
59066
+ if (diff) {
59067
+ const introduced = diff.added.length + diff.escalated.length;
59068
+ L.push(
59069
+ diff.base !== "ok" ? import_chalk32.default.yellow.bold(
59070
+ ` \u26A0\uFE0F base ${diff.base === "did-not-run" ? "could not be read" : "scan was incomplete"} \u2014 cannot say what is new; showing everything`
59071
+ ) : introduced > 0 ? import_chalk32.default.red.bold(
59072
+ ` \u26A0\uFE0F this change introduced ${introduced} finding(s)` + (diff.escalated.length ? ` (${diff.escalated.length} by widening an existing one)` : "")
59073
+ ) : import_chalk32.default.green(
59074
+ ` \u2705 this change introduced nothing` + (diff.unchanged.length ? ` (${diff.unchanged.length} pre-existing)` : "") + (diff.removed.length ? `, and fixed ${diff.removed.length}` : "")
59075
+ )
59076
+ );
59077
+ }
58876
59078
  if (res.incomplete) {
58877
59079
  const rateLimited = res.notes.some((nt) => /rate limit/i.test(nt));
58878
59080
  L.push(
@@ -58912,7 +59114,7 @@ function renderScan(res) {
58912
59114
  L.push(...renderCta(res));
58913
59115
  return L.join("\n");
58914
59116
  }
58915
- function renderScanMarkdown(res) {
59117
+ function renderScanMarkdown(res, diff) {
58916
59118
  const L = [];
58917
59119
  const status = res.worst === "critical" || res.worst === "high" ? "\u26A0\uFE0F" : res.worst ? "\u{1F7E1}" : res.incomplete ? "\u26A0\uFE0F" : "\u2705";
58918
59120
  L.push(`### \u{1F6E1}\uFE0F node9 agent-security \xB7 \`${res.source}\` \xB7 ${status}`);
@@ -58921,25 +59123,57 @@ function renderScanMarkdown(res) {
58921
59123
  `Inspected ${res.inspected.length} config file(s) \xB7 **${res.findings.length} finding(s)**`
58922
59124
  );
58923
59125
  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}`);
59126
+ if (diff && diff.base === "ok") {
59127
+ const introduced = [...diff.added, ...diff.escalated.map((e) => e.finding)];
59128
+ if (introduced.length === 0) {
59129
+ L.push(
59130
+ `\u2705 **This change introduces no agent-security findings.**` + (diff.removed.length ? ` It also fixes ${diff.removed.length}.` : "")
59131
+ );
59132
+ L.push("");
59133
+ } else {
59134
+ L.push(`#### \u26A0\uFE0F Introduced by this change \u2014 ${introduced.length} finding(s)`);
59135
+ L.push("");
59136
+ for (const f of diff.added) findingMd(f, L);
59137
+ for (const e of diff.escalated) {
59138
+ L.push(
59139
+ `> _Guardrail erosion: this finding already existed at **${e.from}** and this change widens it to **${e.to}**._`
59140
+ );
59141
+ L.push("");
59142
+ findingMd(e.finding, L);
59143
+ }
59144
+ }
59145
+ if (diff.removed.length) {
59146
+ L.push(`\u2705 Fixed by this change: ${diff.removed.length} finding(s).`);
59147
+ L.push("");
59148
+ }
59149
+ if (diff.unchanged.length) {
59150
+ L.push(
59151
+ `<details><summary>${diff.unchanged.length} pre-existing finding(s) \u2014 not introduced by this change</summary>`
59152
+ );
59153
+ L.push("");
59154
+ for (const f of diff.unchanged) findingMd(f, L);
59155
+ L.push("</details>");
59156
+ L.push("");
59157
+ }
59158
+ return L.join("\n");
59159
+ }
59160
+ if (diff) {
59161
+ L.push(baseWarning(diff.base));
58932
59162
  L.push("");
58933
59163
  }
59164
+ for (const f of res.findings) findingMd(f, L);
58934
59165
  if (res.findings.length === 0) L.push("No committed agent-security issues found.");
58935
59166
  return L.join("\n");
58936
59167
  }
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;
59168
+ function exitCodeForSeverity(worst, incomplete) {
59169
+ if (worst === "critical" || worst === "high") return 2;
59170
+ if (worst === "medium") return 1;
59171
+ if (incomplete) return 3;
58941
59172
  return 0;
58942
59173
  }
59174
+ function exitCodeFor(res) {
59175
+ return exitCodeForSeverity(res.worst, res.incomplete);
59176
+ }
58943
59177
 
58944
59178
  // src/cli/commands/scan-repo.ts
58945
59179
  var SPIN = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
@@ -58965,23 +59199,36 @@ function makeProgress(target, quiet) {
58965
59199
  };
58966
59200
  }
58967
59201
  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));
59202
+ 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(
59203
+ "--base <ref>",
59204
+ "also scan this git ref and report what the working tree INTRODUCED (local path targets only)"
59205
+ ).option(
59206
+ "--fail-on-introduced",
59207
+ "exit non-zero only for findings this change introduced (requires --base)"
59208
+ ).action(
59209
+ async (target, opts) => {
59210
+ const { onProgress, done } = makeProgress(target, !!(opts.json || opts.markdown));
59211
+ let res;
59212
+ try {
59213
+ res = await scanRepo(target, onProgress);
59214
+ } finally {
59215
+ done();
59216
+ }
59217
+ let diff;
59218
+ if (opts.base) {
59219
+ const baseTree = readGitRefTree(target, opts.base);
59220
+ diff = diffScans(baseTree ? scanTree(baseTree) : null, res);
59221
+ }
59222
+ if (opts.json) {
59223
+ console.log(JSON.stringify(diff ? { ...res, diff } : res, null, 2));
59224
+ } else if (opts.markdown) {
59225
+ console.log(renderScanMarkdown(res, diff));
59226
+ } else {
59227
+ console.log(renderScan(res, diff));
59228
+ }
59229
+ process.exitCode = opts.failOnIntroduced && diff ? exitCodeForSeverity(diff.worstIntroduced, diff.incomplete) : exitCodeFor(res);
58982
59230
  }
58983
- process.exitCode = exitCodeFor(res);
58984
- });
59231
+ );
58985
59232
  }
58986
59233
 
58987
59234
  // src/cli/commands/egress.ts