calllint 0.3.0 → 0.5.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 (3) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +806 -36
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -60,6 +60,7 @@ will do, it says so and never silently upgrades to `SAFE`.
60
60
  ## More
61
61
 
62
62
  - SARIF 2.1.0 for GitHub Code Scanning: `calllint scan <config> --sarif`
63
+ - Editor / agent-host diagnostics JSON (`calllint.diagnostics.v0`): `calllint diagnostics <config> --json`
63
64
  - Self-contained HTML report: `calllint scan <config> --html > report.html`
64
65
  - Drift / rug-pull detection: `calllint baseline <config>` then `calllint verify <config> --ci`
65
66
  - Policy-as-code: `calllint policy init`
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync as readFileSync4 } from "node:fs";
4
+ import { readFileSync as readFileSync6 } from "node:fs";
5
+ import { execFileSync } from "node:child_process";
5
6
 
6
7
  // src/args.ts
7
8
  var EXIT = {
@@ -57,6 +58,7 @@ USAGE
57
58
 
58
59
  COMMANDS
59
60
  scan [target] Scan an MCP config file, or npm:<pkg> / github:<owner/repo>
61
+ diagnostics [target] Emit editor/agent-host diagnostics JSON (calllint.diagnostics.v0)
60
62
  baseline [target] Record the approved risk surface as a baseline
61
63
  verify [target] Compare a fresh scan against the baseline (drift / rug-pull)
62
64
  explain <server> Explain the verdict for one server from the last scan
@@ -70,10 +72,12 @@ TARGETS
70
72
  github:<owner/repo>[@ref] A GitHub repo (requires --online)
71
73
 
72
74
  SCAN OPTIONS
75
+ --changed Scan only the agent-tool configs changed in the git diff
73
76
  --json Emit the ScanReport JSON (stable, emoji-free)
74
77
  --compact One line per server
75
78
  --no-emoji Plain-text symbols (good for CI logs)
76
79
  --sarif Emit SARIF 2.1.0 (GitHub Code Scanning / CI)
80
+ --markdown Emit Markdown for PR comments / GitHub Step Summary
77
81
  --html Emit a self-contained HTML report
78
82
  --policy <file> Use a policy file (default: built-in defaults)
79
83
  --stdin Read config JSON from stdin
@@ -87,8 +91,10 @@ VERIFY OPTIONS
87
91
 
88
92
  EXAMPLES
89
93
  calllint scan .cursor/mcp.json
94
+ calllint scan --changed --markdown
90
95
  cat .cursor/mcp.json | calllint scan --stdin --json
91
96
  calllint scan ./mcp.json --ci --no-emoji
97
+ calllint diagnostics ./mcp.json --json
92
98
  calllint scan npm:mcp-weather@1.0.0
93
99
  calllint scan github:owner/repo --online
94
100
  calllint baseline ./mcp.json
@@ -100,7 +106,7 @@ function helpCommand() {
100
106
  }
101
107
 
102
108
  // src/commands/scan.ts
103
- import { join as join3 } from "node:path";
109
+ import { join as join4, resolve as resolve2 } from "node:path";
104
110
 
105
111
  // ../../packages/policy/src/defaultPolicy.ts
106
112
  function defaultPolicy() {
@@ -166,6 +172,12 @@ function validateOverride(o, index, issues) {
166
172
  message: "must be a valid ISO timestamp (overrides must expire)"
167
173
  });
168
174
  }
175
+ if (o.owner !== void 0 && (typeof o.owner !== "string" || !o.owner.trim())) {
176
+ issues.push({
177
+ path: `${base}.owner`,
178
+ message: "must be a non-empty string when present"
179
+ });
180
+ }
169
181
  const allow = Array.isArray(o.allow) ? o.allow : [];
170
182
  const allowsDangerous = allow.some(
171
183
  (s) => typeof s === "string" && DANGEROUS_SYMBOLS.has(s)
@@ -253,7 +265,7 @@ function applyPolicy(rawVerdict, serverName, blockingFindings, policy, now) {
253
265
  return {
254
266
  verdict: "REVIEW",
255
267
  changed: true,
256
- note: `Policy decision: override for "${serverName}" (expires ${override.expiresAt}) \u2014 ${override.reason}`
268
+ note: `Policy decision: override for "${serverName}" (expires ${override.expiresAt}${override.owner ? `, owner: ${override.owner}` : ""}) \u2014 ${override.reason}`
257
269
  };
258
270
  }
259
271
  function shouldFailCi(verdict, policy) {
@@ -267,7 +279,8 @@ function resolveScanOptions(opts) {
267
279
  policy: opts?.policy ?? defaultPolicy(),
268
280
  now: opts?.now ?? 0,
269
281
  generatedAt: opts?.generatedAt ?? "1970-01-01T00:00:00.000Z",
270
- extraFindings: opts?.extraFindings ?? {}
282
+ extraFindings: opts?.extraFindings ?? {},
283
+ surfaces: opts?.surfaces ?? []
271
284
  };
272
285
  }
273
286
 
@@ -534,6 +547,56 @@ function looksLikeBroadPath(arg) {
534
547
  if (/^[A-Za-z]:\\Users\\[^\\]+\\?$/.test(arg)) return true;
535
548
  return false;
536
549
  }
550
+ function looksLikeHostPath(s) {
551
+ if (!s) return false;
552
+ return s.startsWith("/") || // POSIX absolute (incl. /Users, /home, /etc, ...)
553
+ s.startsWith("~") || // home
554
+ s.startsWith(".") || // workspace-relative (handled as not-broad by isWorkspaceScoped/below)
555
+ /^[A-Za-z]:[\\/]/.test(s) || // Windows drive, C:\ or C:/
556
+ s.startsWith("$") || // $HOME / ${HOME}
557
+ s.startsWith("%");
558
+ }
559
+ function extractDockerHostPaths(args) {
560
+ const hosts = [];
561
+ for (let i = 0; i < args.length; i += 1) {
562
+ const arg = args[i];
563
+ if (arg === "--mount" || arg.startsWith("--mount=")) {
564
+ const csv = arg === "--mount" ? args[i + 1] ?? "" : arg.slice("--mount=".length);
565
+ if (arg === "--mount") i += 1;
566
+ const fields = /* @__PURE__ */ new Map();
567
+ for (const part of csv.split(",")) {
568
+ const eq = part.indexOf("=");
569
+ if (eq === -1) continue;
570
+ fields.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());
571
+ }
572
+ const type = fields.get("type") ?? "volume";
573
+ if (type !== "bind") continue;
574
+ const src = fields.get("source") ?? fields.get("src");
575
+ if (src && looksLikeHostPath(src)) hosts.push(src);
576
+ continue;
577
+ }
578
+ let spec;
579
+ if (arg === "-v" || arg === "--volume") {
580
+ spec = args[i + 1];
581
+ i += 1;
582
+ } else if (arg.startsWith("-v=")) {
583
+ spec = arg.slice("-v=".length);
584
+ } else if (arg.startsWith("--volume=")) {
585
+ spec = arg.slice("--volume=".length);
586
+ }
587
+ if (spec === void 0) continue;
588
+ const host = dockerVolumeHostSide(spec);
589
+ if (host && looksLikeHostPath(host)) hosts.push(host);
590
+ }
591
+ return hosts;
592
+ }
593
+ function dockerVolumeHostSide(spec) {
594
+ const winDrive = /^[A-Za-z]:[\\/]/.test(spec);
595
+ const searchFrom = winDrive ? 2 : 0;
596
+ const sep = spec.indexOf(":", searchFrom);
597
+ if (sep === -1) return void 0;
598
+ return spec.slice(0, sep);
599
+ }
537
600
  function detectBroadFilesystemPath(ctx) {
538
601
  const { server } = ctx;
539
602
  const evidence = [];
@@ -547,6 +610,18 @@ function detectBroadFilesystemPath(ctx) {
547
610
  });
548
611
  }
549
612
  }
613
+ if ((server.command ?? "").toLowerCase() === "docker") {
614
+ for (const host of extractDockerHostPaths(server.args)) {
615
+ if (looksLikeBroadPath(host)) {
616
+ evidence.push({
617
+ type: "config",
618
+ path: server.sourceConfigPath,
619
+ key: "args",
620
+ value: host
621
+ });
622
+ }
623
+ }
624
+ }
550
625
  if (evidence.length === 0) return [];
551
626
  return [
552
627
  {
@@ -727,7 +802,7 @@ function detectUnknownRemote(ctx) {
727
802
  ];
728
803
  }
729
804
 
730
- // ../../packages/static-analyzer/src/detectors/promptPoisoning.ts
805
+ // ../../packages/static-analyzer/src/promptScan.ts
731
806
  var POISON_PATTERNS = [
732
807
  "ignore previous instructions",
733
808
  "ignore all previous instructions",
@@ -749,23 +824,69 @@ var POISON_PATTERNS = [
749
824
  "override the system prompt",
750
825
  "you are now"
751
826
  ];
752
- function scanText(text, source) {
827
+ function findPoisonPhrases(text) {
753
828
  if (!text) return [];
754
829
  const lower = text.toLowerCase();
755
830
  const hits = [];
756
831
  for (const pattern of POISON_PATTERNS) {
757
- if (lower.includes(pattern)) {
758
- hits.push({
759
- pattern,
760
- evidence: {
761
- type: "tool-metadata",
762
- path: source.path,
763
- key: source.key,
764
- snippet: pattern
765
- }
766
- });
832
+ if (lower.includes(pattern)) hits.push(pattern);
833
+ }
834
+ return hits;
835
+ }
836
+ var INVISIBLE_CODE_POINTS = [8203, 8204, 8205, 8288, 65279];
837
+ var BIDI_RANGES = [
838
+ [8234, 8238],
839
+ [8294, 8297]
840
+ ];
841
+ var TAG_RANGE = [917504, 917631];
842
+ function hasInvisible(text) {
843
+ for (const ch of text) {
844
+ if (INVISIBLE_CODE_POINTS.includes(ch.codePointAt(0))) return true;
845
+ }
846
+ return false;
847
+ }
848
+ function inAnyRange(text, ranges) {
849
+ for (const ch of text) {
850
+ const cp = ch.codePointAt(0);
851
+ for (const [lo, hi] of ranges) {
852
+ if (cp >= lo && cp <= hi) return true;
767
853
  }
768
854
  }
855
+ return false;
856
+ }
857
+ function findHiddenContent(text) {
858
+ if (!text) return [];
859
+ const categories = [];
860
+ if (hasInvisible(text)) categories.push("zero-width or invisible characters");
861
+ if (inAnyRange(text, BIDI_RANGES)) {
862
+ categories.push("Unicode bidirectional override controls");
863
+ }
864
+ if (inAnyRange(text, [TAG_RANGE])) {
865
+ categories.push("invisible tag-character ASCII smuggling");
866
+ }
867
+ if (/<!--[\s\S]*?-->/.test(text)) categories.push("embedded HTML/XML comment");
868
+ return categories;
869
+ }
870
+ function poisonEvidence(pattern, source) {
871
+ return { type: source.type, path: source.path, key: source.key, snippet: pattern };
872
+ }
873
+ function hiddenEvidence(category, source) {
874
+ return { type: source.type, path: source.path, key: source.key, snippet: category };
875
+ }
876
+
877
+ // ../../packages/static-analyzer/src/detectors/promptPoisoning.ts
878
+ function scanText(text, source) {
879
+ const hits = [];
880
+ for (const pattern of findPoisonPhrases(text)) {
881
+ hits.push({
882
+ pattern,
883
+ evidence: poisonEvidence(pattern, {
884
+ type: "tool-metadata",
885
+ path: source.path,
886
+ key: source.key
887
+ })
888
+ });
889
+ }
769
890
  return hits;
770
891
  }
771
892
  function detectPromptPoisoning(ctx) {
@@ -1009,6 +1130,93 @@ function detectFinancialAction(ctx) {
1009
1130
  return findings;
1010
1131
  }
1011
1132
 
1133
+ // ../../packages/static-analyzer/src/detectors/unverifiedLocalSource.ts
1134
+ function localSubject(args) {
1135
+ for (const a of args) {
1136
+ if (a === "-y" || a === "--yes" || a.startsWith("-")) continue;
1137
+ return a;
1138
+ }
1139
+ return void 0;
1140
+ }
1141
+ function detectUnverifiedLocalSource(ctx) {
1142
+ const { binding, server } = ctx;
1143
+ if (!binding.sourceKnown) return [];
1144
+ if (!binding.runtimeExecutable) return [];
1145
+ if (binding.runtimeKind === "docker") return [];
1146
+ if (binding.packageName) return [];
1147
+ const command = binding.declaredCommand ?? server.command;
1148
+ if (!command) return [];
1149
+ const subject = localSubject(binding.declaredArgs);
1150
+ const display = subject ? `${command} ${subject}` : command;
1151
+ const evidence = [
1152
+ {
1153
+ type: "runtime-binding",
1154
+ path: server.sourceConfigPath,
1155
+ key: "command",
1156
+ value: display
1157
+ }
1158
+ ];
1159
+ return [
1160
+ {
1161
+ id: "exec.unverified-local-source",
1162
+ title: "Runs an unverified local executable",
1163
+ severity: "medium",
1164
+ blocker: false,
1165
+ symbol: "EXEC",
1166
+ riskClass: "S2",
1167
+ mode: "OBSERVED",
1168
+ confidence: "medium",
1169
+ detectionMethod: "runtime-binding",
1170
+ evidence,
1171
+ impact: "The runtime executes a local script or binary whose contents CallLint never inspects. It is not a recognized package or pinned image, so what actually runs cannot be verified from the config alone.",
1172
+ fix: "Confirm you trust the local source, or run it from a pinned, recognized package (e.g. npx pkg@1.2.3 / a pinned docker image) so the runtime is independently verifiable.",
1173
+ falsePositiveNote: "A developer running their own local server (e.g. node ./dist/server.js) is normal; this flags that the source is not independently verifiable, not that it is malicious."
1174
+ }
1175
+ ];
1176
+ }
1177
+
1178
+ // ../../packages/static-analyzer/src/detectors/hiddenInstructions.ts
1179
+ function scanHidden(text, key, path) {
1180
+ const out = [];
1181
+ for (const category of findHiddenContent(text)) {
1182
+ out.push(hiddenEvidence(category, { type: "tool-metadata", path, key }));
1183
+ }
1184
+ return out;
1185
+ }
1186
+ function detectHiddenInstructions(ctx) {
1187
+ const { server } = ctx;
1188
+ const evidence = [];
1189
+ evidence.push(...scanHidden(server.instructions, "instructions", server.sourceConfigPath));
1190
+ for (const tool of server.providedTools) {
1191
+ const base = tool.name ? `tools.${tool.name}` : "tools";
1192
+ evidence.push(...scanHidden(tool.name, `${base}.name`, server.sourceConfigPath));
1193
+ evidence.push(
1194
+ ...scanHidden(tool.description, `${base}.description`, server.sourceConfigPath)
1195
+ );
1196
+ evidence.push(
1197
+ ...scanHidden(tool.inputSchemaText, `${base}.inputSchema`, server.sourceConfigPath)
1198
+ );
1199
+ }
1200
+ if (evidence.length === 0) return [];
1201
+ return [
1202
+ {
1203
+ id: "prompt.hidden-instructions",
1204
+ title: "Hidden or obfuscated content in model-visible metadata",
1205
+ severity: "medium",
1206
+ blocker: false,
1207
+ symbol: "PROMPT",
1208
+ riskClass: "S2",
1209
+ mode: "OBSERVED",
1210
+ confidence: "medium",
1211
+ detectionMethod: "tool-metadata",
1212
+ evidence,
1213
+ impact: "Invisible or obfuscated characters in tool metadata reach the model but not a human reader, so a model-directed instruction can hide from review while still steering autonomous tool use.",
1214
+ fix: "Remove zero-width/bidirectional/tag characters and HTML comments from tool names, descriptions, schemas, and server instructions; keep model-visible text identical to what a human reviewer sees.",
1215
+ falsePositiveNote: "Some hidden characters are benign (e.g. a BOM, or a bidi control in a legitimate right-to-left language sample, or an HTML comment in documentation). This flags that the model-visible text differs from the rendered text; review the surface in context."
1216
+ }
1217
+ ];
1218
+ }
1219
+
1012
1220
  // ../../packages/static-analyzer/src/analyzeServerConfig.ts
1013
1221
  var DETECTORS = [
1014
1222
  detectBroadFilesystemPath,
@@ -1018,7 +1226,9 @@ var DETECTORS = [
1018
1226
  detectUnpinnedPackage,
1019
1227
  detectUnknownRemote,
1020
1228
  detectExternalMutation,
1021
- detectFinancialAction
1229
+ detectFinancialAction,
1230
+ detectUnverifiedLocalSource,
1231
+ detectHiddenInstructions
1022
1232
  ];
1023
1233
  function analyzeServerConfig(server) {
1024
1234
  const binding = resolveRuntimeBinding(server);
@@ -1030,6 +1240,41 @@ function analyzeServerConfig(server) {
1030
1240
  return findings;
1031
1241
  }
1032
1242
 
1243
+ // ../../packages/static-analyzer/src/documentSurface.ts
1244
+ function analyzeDocumentSurfaces(surfaces) {
1245
+ const evidence = [];
1246
+ for (const surface of surfaces) {
1247
+ for (const pattern of findPoisonPhrases(surface.text)) {
1248
+ evidence.push(
1249
+ poisonEvidence(pattern, { type: "source", path: surface.path, key: surface.kind })
1250
+ );
1251
+ }
1252
+ for (const category of findHiddenContent(surface.text)) {
1253
+ evidence.push(
1254
+ hiddenEvidence(category, { type: "source", path: surface.path, key: surface.kind })
1255
+ );
1256
+ }
1257
+ }
1258
+ if (evidence.length === 0) return [];
1259
+ return [
1260
+ {
1261
+ id: "prompt.surface-instructions",
1262
+ title: "Model-directed or hidden content in a project document",
1263
+ severity: "medium",
1264
+ blocker: false,
1265
+ symbol: "PROMPT",
1266
+ riskClass: "S2",
1267
+ mode: "OBSERVED",
1268
+ confidence: "medium",
1269
+ detectionMethod: "source-text",
1270
+ evidence,
1271
+ impact: "A project document (README / SKILL.md / AGENTS.md / package description) contains model-directed instructions or hidden/obfuscated content. An agent that reads project docs alongside the tool could be steered by text a human reviewer skims past.",
1272
+ fix: "Remove model-directed instructions and hidden/obfuscated characters from project documents; keep their visible text equal to their intent.",
1273
+ falsePositiveNote: "Documentation legitimately discusses prompts, tool ordering, or includes HTML comments and non-Latin scripts. This flags prompt-surface shape in project docs, not a proven injection; review the cited file in context."
1274
+ }
1275
+ ];
1276
+ }
1277
+
1033
1278
  // ../../packages/risk-engine/src/computeRiskClass.ts
1034
1279
  function computeRiskClass(findings, binding) {
1035
1280
  const classes = findings.map((f) => f.riskClass);
@@ -1387,6 +1632,126 @@ function normalizeMcpServers(root, sourceConfigPath) {
1387
1632
 
1388
1633
  // ../../packages/config-parser/src/parseConfig.ts
1389
1634
  import { basename } from "node:path";
1635
+
1636
+ // ../../packages/config-parser/src/positionIndex.ts
1637
+ function peek(c) {
1638
+ return c.text[c.i] ?? "";
1639
+ }
1640
+ function advance(c) {
1641
+ const ch = c.text[c.i] ?? "";
1642
+ c.i++;
1643
+ if (ch === "\n") {
1644
+ c.line++;
1645
+ c.col = 1;
1646
+ } else {
1647
+ c.col++;
1648
+ }
1649
+ return ch;
1650
+ }
1651
+ function skipWhitespace(c) {
1652
+ while (c.i < c.text.length) {
1653
+ const ch = peek(c);
1654
+ if (ch === " " || ch === " " || ch === "\n" || ch === "\r") advance(c);
1655
+ else break;
1656
+ }
1657
+ }
1658
+ function readString(c) {
1659
+ advance(c);
1660
+ let out = "";
1661
+ while (c.i < c.text.length) {
1662
+ const ch = advance(c);
1663
+ if (ch === "\\") {
1664
+ const esc2 = advance(c);
1665
+ if (esc2 === "n") out += "\n";
1666
+ else if (esc2 === "t") out += " ";
1667
+ else if (esc2 === "r") out += "\r";
1668
+ else if (esc2 === "u") {
1669
+ let hex = "";
1670
+ for (let k = 0; k < 4; k++) hex += advance(c);
1671
+ const code = Number.parseInt(hex, 16);
1672
+ out += Number.isNaN(code) ? "" : String.fromCharCode(code);
1673
+ } else out += esc2;
1674
+ } else if (ch === '"') {
1675
+ break;
1676
+ } else {
1677
+ out += ch;
1678
+ }
1679
+ }
1680
+ return out;
1681
+ }
1682
+ function skipValue(c, index, pathPrefix) {
1683
+ skipWhitespace(c);
1684
+ const ch = peek(c);
1685
+ if (ch === "{") {
1686
+ scanObject(c, index, pathPrefix);
1687
+ } else if (ch === "[") {
1688
+ scanArray(c, index, pathPrefix);
1689
+ } else if (ch === '"') {
1690
+ readString(c);
1691
+ } else {
1692
+ while (c.i < c.text.length) {
1693
+ const n = peek(c);
1694
+ if (n === "," || n === "}" || n === "]" || n === " " || n === " " || n === "\n" || n === "\r") break;
1695
+ advance(c);
1696
+ }
1697
+ }
1698
+ }
1699
+ function scanArray(c, index, pathPrefix) {
1700
+ advance(c);
1701
+ for (; ; ) {
1702
+ skipWhitespace(c);
1703
+ const ch = peek(c);
1704
+ if (ch === "]" || ch === "") {
1705
+ if (ch === "]") advance(c);
1706
+ return;
1707
+ }
1708
+ if (ch === ",") {
1709
+ advance(c);
1710
+ continue;
1711
+ }
1712
+ skipValue(c, index, pathPrefix);
1713
+ }
1714
+ }
1715
+ function scanObject(c, index, pathPrefix) {
1716
+ advance(c);
1717
+ for (; ; ) {
1718
+ skipWhitespace(c);
1719
+ const ch = peek(c);
1720
+ if (ch === "}" || ch === "") {
1721
+ if (ch === "}") advance(c);
1722
+ return;
1723
+ }
1724
+ if (ch === ",") {
1725
+ advance(c);
1726
+ continue;
1727
+ }
1728
+ if (ch !== '"') {
1729
+ advance(c);
1730
+ continue;
1731
+ }
1732
+ const keyLine = c.line;
1733
+ const keyCol = c.col;
1734
+ const key = readString(c);
1735
+ const path = pathPrefix ? `${pathPrefix}.${key}` : key;
1736
+ if (!(path in index)) index[path] = { line: keyLine, column: keyCol };
1737
+ skipWhitespace(c);
1738
+ if (peek(c) === ":") advance(c);
1739
+ skipValue(c, index, path);
1740
+ }
1741
+ }
1742
+ function buildPositionIndex(text) {
1743
+ const index = {};
1744
+ const c = { text, i: 0, line: 1, col: 1 };
1745
+ try {
1746
+ skipWhitespace(c);
1747
+ if (peek(c) === "{") scanObject(c, index, "");
1748
+ else if (peek(c) === "[") scanArray(c, index, "");
1749
+ } catch {
1750
+ }
1751
+ return index;
1752
+ }
1753
+
1754
+ // ../../packages/config-parser/src/parseConfig.ts
1390
1755
  function kindForPath(path) {
1391
1756
  const base = basename(path).toLowerCase();
1392
1757
  if (base.includes("settings")) return "claude-settings";
@@ -1399,10 +1764,84 @@ function parseConfigText(text, configPath = "<inline>") {
1399
1764
  configPath,
1400
1765
  kind: configPath === "<inline>" ? "inline" : kindForPath(configPath),
1401
1766
  servers: normalizeMcpServers(root, configPath),
1402
- root
1767
+ root,
1768
+ positions: buildPositionIndex(text)
1403
1769
  };
1404
1770
  }
1405
1771
 
1772
+ // ../../packages/core/src/scanSurfaces.ts
1773
+ function scanDocumentSurfaces(surfaces, configPath, generatedAt) {
1774
+ if (surfaces.length === 0) return void 0;
1775
+ const findings = analyzeDocumentSurfaces(surfaces);
1776
+ if (findings.length === 0) return void 0;
1777
+ const truncated = surfaces.some((s) => s.truncated);
1778
+ return {
1779
+ schemaVersion: "calllint.report.v0",
1780
+ reportKind: "single-target",
1781
+ target: {
1782
+ name: "project-docs",
1783
+ kind: "project-docs",
1784
+ configPath
1785
+ },
1786
+ verdict: "REVIEW",
1787
+ publicVerdictLabel: VERDICT_PUBLIC_LABEL.REVIEW,
1788
+ riskClass: "S2",
1789
+ symbols: ["PROMPT"],
1790
+ confidence: "medium",
1791
+ reproducibility: {
1792
+ level: "HIGH",
1793
+ reasons: ["Static scan of local documents; deterministic given the same files."]
1794
+ },
1795
+ summary: "Project documents contain model-directed or hidden prompt-surface content; review the cited files.",
1796
+ observed: findings,
1797
+ inferred: [],
1798
+ findings,
1799
+ topFindings: findings,
1800
+ policy: {
1801
+ autonomousUse: "warn",
1802
+ manualApproval: "recommended",
1803
+ sandbox: "recommended"
1804
+ },
1805
+ fingerprints: {
1806
+ configHash: "",
1807
+ targetSpecHash: "",
1808
+ riskSurfaceHash: ""
1809
+ },
1810
+ diagnostics: truncated ? [
1811
+ {
1812
+ level: "info",
1813
+ code: "surface.truncated",
1814
+ message: "One or more project documents exceeded the scan size cap and were truncated."
1815
+ }
1816
+ ] : [],
1817
+ generatedAt
1818
+ };
1819
+ }
1820
+
1821
+ // ../../packages/core/src/enrichPositions.ts
1822
+ function enrichEvidencePositions(reports, positions) {
1823
+ if (Object.keys(positions).length === 0) return;
1824
+ const prefixes = ["mcpServers", "servers", ""];
1825
+ for (const report of reports) {
1826
+ const server = report.target.name;
1827
+ for (const finding of report.findings) {
1828
+ for (const ev of finding.evidence) {
1829
+ if (ev.line !== void 0) continue;
1830
+ if (!ev.key) continue;
1831
+ for (const prefix of prefixes) {
1832
+ const path = prefix ? `${prefix}.${server}.${ev.key}` : `${server}.${ev.key}`;
1833
+ const pos = positions[path];
1834
+ if (pos) {
1835
+ ev.line = pos.line;
1836
+ ev.column = pos.column;
1837
+ break;
1838
+ }
1839
+ }
1840
+ }
1841
+ }
1842
+ }
1843
+ }
1844
+
1406
1845
  // ../../packages/core/src/scanConfig.ts
1407
1846
  function aggregate(configPath, reports, generatedAt) {
1408
1847
  const counts = { SAFE: 0, REVIEW: 0, BLOCK: 0, UNKNOWN: 0 };
@@ -1421,10 +1860,13 @@ function aggregate(configPath, reports, generatedAt) {
1421
1860
  };
1422
1861
  }
1423
1862
  function scanParsed(parsed, opts) {
1424
- const { generatedAt } = resolveScanOptions(opts);
1863
+ const { generatedAt, surfaces } = resolveScanOptions(opts);
1425
1864
  const reports = parsed.servers.map(
1426
1865
  (server) => scanServer({ server, targetKind: parsed.kind }, opts)
1427
1866
  );
1867
+ enrichEvidencePositions(reports, parsed.positions);
1868
+ const surfaceReport = scanDocumentSurfaces(surfaces, parsed.configPath, generatedAt);
1869
+ if (surfaceReport) reports.push(surfaceReport);
1428
1870
  return aggregate(parsed.configPath, reports, generatedAt);
1429
1871
  }
1430
1872
  function scanConfigText(text, configPath, opts) {
@@ -1872,6 +2314,146 @@ function renderSarif(summary) {
1872
2314
  return JSON.stringify(sarif, null, 2);
1873
2315
  }
1874
2316
 
2317
+ // ../../packages/report-renderer/src/renderMarkdown.ts
2318
+ function renderMarkdown(summary) {
2319
+ const out = [];
2320
+ const c = summary.counts;
2321
+ out.push(`## CallLint: ${summary.verdict} \u2014 ${summary.publicVerdictLabel}`);
2322
+ out.push("");
2323
+ out.push(
2324
+ `CallLint statically checked \`${summary.configPath}\` before the agent tool runs. It never executes, installs, or connects to the server it judges.`
2325
+ );
2326
+ out.push("");
2327
+ out.push("| Server | Verdict | Risk class | Findings |");
2328
+ out.push("| --- | --- | --- | --- |");
2329
+ for (const r of summary.reports) {
2330
+ out.push(
2331
+ `| ${mdCell(r.target.name)} | ${r.verdict} | ${r.riskClass} | ${r.findings.length} |`
2332
+ );
2333
+ }
2334
+ out.push(
2335
+ `| **TOTAL** | **${summary.verdict}** | \u2014 | BLOCK ${c.BLOCK} \xB7 UNKNOWN ${c.UNKNOWN} \xB7 REVIEW ${c.REVIEW} \xB7 SAFE ${c.SAFE} |`
2336
+ );
2337
+ out.push("");
2338
+ for (const r of summary.reports) {
2339
+ renderServer2(out, r);
2340
+ }
2341
+ out.push("---");
2342
+ out.push("");
2343
+ out.push(
2344
+ "> CallLint does not prove runtime safety. `SAFE` means no blockers observed under current evidence; `UNKNOWN` is never `SAFE`. Verdicts are heuristic decision support, not a safety guarantee."
2345
+ );
2346
+ return out.join("\n");
2347
+ }
2348
+ function renderServer2(out, r) {
2349
+ out.push(`### ${r.target.name} \u2014 ${r.verdict}`);
2350
+ out.push("");
2351
+ if (r.summary) {
2352
+ out.push(r.summary);
2353
+ out.push("");
2354
+ }
2355
+ if (r.findings.length === 0) {
2356
+ out.push("_No findings._");
2357
+ out.push("");
2358
+ } else {
2359
+ for (const f of r.findings) {
2360
+ renderFinding(out, f);
2361
+ }
2362
+ }
2363
+ renderRecommendation(out, r.policy);
2364
+ out.push("");
2365
+ }
2366
+ function renderFinding(out, f) {
2367
+ const tag = f.blocker ? "BLOCKER" : f.severity.toUpperCase();
2368
+ out.push(`#### ${tag}: ${f.title}`);
2369
+ out.push("");
2370
+ out.push(`- Risk: ${f.symbol} \xB7 ${f.riskClass} \xB7 ${f.mode} \xB7 confidence ${f.confidence}`);
2371
+ const ev = f.evidence[0];
2372
+ if (ev) {
2373
+ const loc = ev.path ? ` \`${ev.path}\`${ev.line ? `:${ev.line}` : ""}` : "";
2374
+ const shown = ev.snippet ?? ev.value ?? ev.key;
2375
+ out.push(`- Evidence:${loc}`);
2376
+ if (shown) {
2377
+ out.push("");
2378
+ out.push(" ```text");
2379
+ for (const line of String(shown).split("\n")) out.push(` ${line}`);
2380
+ out.push(" ```");
2381
+ }
2382
+ }
2383
+ out.push("");
2384
+ out.push(`Why it matters: ${f.impact}`);
2385
+ out.push("");
2386
+ out.push(`Recommended fix: ${f.fix}`);
2387
+ if (f.falsePositiveNote) {
2388
+ out.push("");
2389
+ out.push(`Note: ${f.falsePositiveNote}`);
2390
+ }
2391
+ out.push("");
2392
+ }
2393
+ function renderRecommendation(out, p) {
2394
+ out.push("Policy recommendation:");
2395
+ out.push(`- Autonomous use: ${p.autonomousUse}`);
2396
+ out.push(`- Manual approval: ${p.manualApproval}`);
2397
+ out.push(`- Sandbox: ${p.sandbox}`);
2398
+ }
2399
+ function mdCell(s) {
2400
+ return s.replace(/\|/g, "\\|").replace(/\n/g, " ");
2401
+ }
2402
+
2403
+ // ../../packages/report-renderer/src/renderDiagnostics.ts
2404
+ function verdictContributionFor(f) {
2405
+ if (f.blocker) return "blocker";
2406
+ return f.mode === "INFERRED" ? "inferred" : "review";
2407
+ }
2408
+ function keyPathFor(f) {
2409
+ const ev = f.evidence[0];
2410
+ if (!ev) return null;
2411
+ return ev.key ?? null;
2412
+ }
2413
+ function observedFor(f) {
2414
+ const ev = f.evidence[0];
2415
+ if (!ev) return null;
2416
+ return ev.value ?? ev.snippet ?? null;
2417
+ }
2418
+ function entryFromFinding(f, report, configFile) {
2419
+ const ev = f.evidence[0];
2420
+ return {
2421
+ ruleId: f.id,
2422
+ title: f.title,
2423
+ severity: f.severity,
2424
+ server: report.target.name,
2425
+ file: ev?.path ?? configFile,
2426
+ keyPath: keyPathFor(f),
2427
+ // Populated by the post-hoc position enrichment (config-parser position
2428
+ // index → core), when the evidence's config key maps to a source location;
2429
+ // null for evidence with no locatable source key (e.g. binding-derived).
2430
+ line: ev?.line ?? null,
2431
+ column: ev?.column ?? null,
2432
+ observed: observedFor(f),
2433
+ remediation: f.fix,
2434
+ mode: f.mode,
2435
+ confidence: f.confidence,
2436
+ verdictContribution: verdictContributionFor(f)
2437
+ };
2438
+ }
2439
+ function renderDiagnostics(summary) {
2440
+ const diagnostics = [];
2441
+ for (const report of summary.reports) {
2442
+ for (const f of report.findings) {
2443
+ diagnostics.push(entryFromFinding(f, report, summary.configPath));
2444
+ }
2445
+ }
2446
+ const out = {
2447
+ schemaVersion: "calllint.diagnostics.v0",
2448
+ verdict: summary.verdict,
2449
+ publicVerdictLabel: summary.publicVerdictLabel,
2450
+ file: summary.configPath,
2451
+ diagnostics,
2452
+ generatedAt: summary.generatedAt
2453
+ };
2454
+ return JSON.stringify(out, null, 2);
2455
+ }
2456
+
1875
2457
  // ../../packages/report-renderer/src/logoBase64.ts
1876
2458
  var LOGO_REPORT_BASE64 = "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";
1877
2459
 
@@ -2066,8 +2648,78 @@ function resolveConfigInput(args, deps) {
2066
2648
  return { text: readFileSync3(resolved, "utf8"), configPath: resolved };
2067
2649
  }
2068
2650
 
2651
+ // src/commands/changedConfigs.ts
2652
+ import { basename as basename2, resolve } from "node:path";
2653
+ import { existsSync as existsSync3 } from "node:fs";
2654
+ function changedConfigPaths(cwd, diff) {
2655
+ let raw;
2656
+ try {
2657
+ raw = diff();
2658
+ } catch {
2659
+ return [];
2660
+ }
2661
+ const knownLeaves = new Set(DEFAULT_CONFIG_PATHS.map((p) => basename2(p)));
2662
+ const knownPaths = new Set(DEFAULT_CONFIG_PATHS);
2663
+ return raw.split("\n").map((l) => l.trim()).filter(
2664
+ (f) => f.length > 0 && (knownPaths.has(f) || DEFAULT_CONFIG_PATHS.some((p) => f.endsWith("/" + p)) || knownLeaves.has(basename2(f)))
2665
+ ).map((f) => resolve(cwd, f)).filter((abs) => existsSync3(abs));
2666
+ }
2667
+
2668
+ // src/commands/surfaces.ts
2669
+ import { existsSync as existsSync4, readFileSync as readFileSync4, statSync } from "node:fs";
2670
+ import { join as join3 } from "node:path";
2671
+ var SURFACE_SIZE_CAP = 256 * 1024;
2672
+ var SURFACE_FILES = [
2673
+ { file: "README.md", kind: "readme" },
2674
+ { file: "SKILL.md", kind: "skill" },
2675
+ { file: "AGENTS.md", kind: "agents" }
2676
+ ];
2677
+ function readCapped(path) {
2678
+ try {
2679
+ const st = statSync(path);
2680
+ if (!st.isFile()) return void 0;
2681
+ const raw = readFileSync4(path, "utf8");
2682
+ if (raw.length > SURFACE_SIZE_CAP) {
2683
+ return { text: raw.slice(0, SURFACE_SIZE_CAP), truncated: true };
2684
+ }
2685
+ return { text: raw, truncated: false };
2686
+ } catch {
2687
+ return void 0;
2688
+ }
2689
+ }
2690
+ function readDocumentSurfaces(dir) {
2691
+ const surfaces = [];
2692
+ if (!existsSync4(dir)) return surfaces;
2693
+ for (const { file, kind } of SURFACE_FILES) {
2694
+ const got = readCapped(join3(dir, file));
2695
+ if (got) surfaces.push({ path: file, kind, text: got.text, truncated: got.truncated });
2696
+ }
2697
+ const pkgPath = join3(dir, "package.json");
2698
+ const pkgRaw = readCapped(pkgPath);
2699
+ if (pkgRaw) {
2700
+ try {
2701
+ const pkg = JSON.parse(pkgRaw.text);
2702
+ const description = pkg && typeof pkg === "object" && !Array.isArray(pkg) ? pkg.description : void 0;
2703
+ if (typeof description === "string") {
2704
+ surfaces.push({
2705
+ path: "package.json",
2706
+ kind: "package-description",
2707
+ text: description,
2708
+ truncated: pkgRaw.truncated
2709
+ });
2710
+ }
2711
+ } catch {
2712
+ }
2713
+ }
2714
+ return surfaces;
2715
+ }
2716
+
2069
2717
  // src/commands/scan.ts
2718
+ import { readFileSync as readFileSync5 } from "node:fs";
2070
2719
  function scanCommand(args, deps) {
2720
+ if (flagBool(args.flags, "changed")) {
2721
+ return scanChangedCommand(args, deps);
2722
+ }
2071
2723
  const policyPath = flagStr(args.flags, "policy");
2072
2724
  let policy;
2073
2725
  try {
@@ -2084,13 +2736,19 @@ function scanCommand(args, deps) {
2084
2736
  return { stdout: "", stderr: input.error, exitCode: input.exitCode };
2085
2737
  }
2086
2738
  const { text, configPath } = input;
2739
+ return scanOneConfig(text, configPath, policy, args, deps);
2740
+ }
2741
+ function scanOneConfig(text, configPath, policy, args, deps) {
2742
+ const surfaceDir = flagStr(args.flags, "surface-dir");
2743
+ const surfaces = surfaceDir ? readDocumentSurfaces(resolve2(deps.cwd, surfaceDir)) : void 0;
2087
2744
  let summary;
2088
2745
  try {
2089
2746
  summary = scanConfigText(text, configPath, {
2090
2747
  policy,
2091
2748
  now: deps.now,
2092
2749
  generatedAt: deps.generatedAt,
2093
- extraFindings: deps.online?.extraFindings
2750
+ extraFindings: deps.online?.extraFindings,
2751
+ surfaces
2094
2752
  });
2095
2753
  } catch (err) {
2096
2754
  if (err instanceof ConfigParseError) {
@@ -2104,23 +2762,113 @@ function scanCommand(args, deps) {
2104
2762
  }
2105
2763
  if (deps.writeCacheFile !== false) {
2106
2764
  try {
2107
- writeCache(summary, join3(deps.cwd, ".calllint", "last-scan.json"));
2765
+ writeCache(summary, join4(deps.cwd, ".calllint", "last-scan.json"));
2108
2766
  } catch {
2109
2767
  }
2110
2768
  }
2769
+ const stdout = renderSummary(summary, args);
2770
+ const exitCode = flagBool(args.flags, "ci") ? exitCodeFor(summary, policy) : EXIT.OK;
2771
+ return { stdout, exitCode };
2772
+ }
2773
+ function renderSummary(summary, args) {
2111
2774
  const style = flagBool(args.flags, "no-emoji") ? NO_EMOJI_STYLE : DEFAULT_STYLE;
2775
+ if (flagBool(args.flags, "json")) return renderJson(summary);
2776
+ if (flagBool(args.flags, "sarif")) return renderSarif(summary);
2777
+ if (flagBool(args.flags, "markdown")) return renderMarkdown(summary);
2778
+ if (flagBool(args.flags, "html")) return renderHtml(summary);
2779
+ if (flagBool(args.flags, "compact")) return renderCompact(summary, style);
2780
+ return renderTerminal(summary, style);
2781
+ }
2782
+ function scanChangedCommand(args, deps) {
2783
+ const policyPath = flagStr(args.flags, "policy");
2784
+ let policy;
2785
+ try {
2786
+ policy = loadPolicyOrDefault(policyPath);
2787
+ } catch (err) {
2788
+ return {
2789
+ stdout: "",
2790
+ stderr: err instanceof Error ? err.message : String(err),
2791
+ exitCode: EXIT.ERROR
2792
+ };
2793
+ }
2794
+ if (!deps.getChangedFilesDiff) {
2795
+ return {
2796
+ stdout: "",
2797
+ stderr: "--changed needs a git diff source. Run inside a git repository, or scan a path directly.",
2798
+ exitCode: EXIT.USAGE
2799
+ };
2800
+ }
2801
+ const paths = changedConfigPaths(deps.cwd, deps.getChangedFilesDiff);
2802
+ if (paths.length === 0) {
2803
+ return {
2804
+ stdout: "No agent-tool configs changed in the git diff. Nothing to scan.",
2805
+ exitCode: EXIT.OK
2806
+ };
2807
+ }
2808
+ const results = paths.map((p) => {
2809
+ const text = readFileSync5(p, "utf8");
2810
+ return scanOneConfig(text, p, policy, args, deps);
2811
+ });
2812
+ const exitCode = results.reduce((worst, r) => Math.max(worst, r.exitCode), EXIT.OK);
2112
2813
  let stdout;
2113
- if (flagBool(args.flags, "json")) stdout = renderJson(summary);
2114
- else if (flagBool(args.flags, "sarif")) stdout = renderSarif(summary);
2115
- else if (flagBool(args.flags, "html")) stdout = renderHtml(summary);
2116
- else if (flagBool(args.flags, "compact")) stdout = renderCompact(summary, style);
2117
- else stdout = renderTerminal(summary, style);
2814
+ if (flagBool(args.flags, "json")) {
2815
+ stdout = "[" + results.map((r) => r.stdout).join(",\n") + "]";
2816
+ } else {
2817
+ stdout = results.map((r) => r.stdout).join("\n\n---\n\n");
2818
+ }
2819
+ const stderr = results.map((r) => r.stderr).filter(Boolean).join("\n");
2820
+ return { stdout, exitCode, ...stderr ? { stderr } : {} };
2821
+ }
2822
+
2823
+ // src/commands/diagnostics.ts
2824
+ function diagnosticsCommand(args, deps) {
2825
+ if (!flagBool(args.flags, "json")) {
2826
+ return {
2827
+ stdout: "",
2828
+ stderr: "diagnostics v0 emits JSON only \u2014 pass --json (calllint.diagnostics.v0).",
2829
+ exitCode: EXIT.USAGE
2830
+ };
2831
+ }
2832
+ let policy;
2833
+ try {
2834
+ policy = loadPolicyOrDefault(flagStr(args.flags, "policy"));
2835
+ } catch (err) {
2836
+ return {
2837
+ stdout: "",
2838
+ stderr: err instanceof Error ? err.message : String(err),
2839
+ exitCode: EXIT.ERROR
2840
+ };
2841
+ }
2842
+ const input = deps.online?.inputOverride ?? resolveConfigInput(args, deps);
2843
+ if (isInputError(input)) {
2844
+ return { stdout: "", stderr: input.error, exitCode: input.exitCode };
2845
+ }
2846
+ const { text, configPath } = input;
2847
+ let summary;
2848
+ try {
2849
+ summary = scanConfigText(text, configPath, {
2850
+ policy,
2851
+ now: deps.now,
2852
+ generatedAt: deps.generatedAt,
2853
+ extraFindings: deps.online?.extraFindings
2854
+ });
2855
+ } catch (err) {
2856
+ if (err instanceof ConfigParseError) {
2857
+ return {
2858
+ stdout: "",
2859
+ stderr: `Parse error in ${configPath}: ${err.message}`,
2860
+ exitCode: EXIT.ERROR
2861
+ };
2862
+ }
2863
+ throw err;
2864
+ }
2865
+ const stdout = renderDiagnostics(summary);
2118
2866
  const exitCode = flagBool(args.flags, "ci") ? exitCodeFor(summary, policy) : EXIT.OK;
2119
2867
  return { stdout, exitCode };
2120
2868
  }
2121
2869
 
2122
2870
  // src/commands/explain.ts
2123
- import { join as join4 } from "node:path";
2871
+ import { join as join5 } from "node:path";
2124
2872
  function explainCommand(args, deps) {
2125
2873
  const serverName = args.positionals[0];
2126
2874
  if (!serverName) {
@@ -2130,7 +2878,7 @@ function explainCommand(args, deps) {
2130
2878
  exitCode: EXIT.USAGE
2131
2879
  };
2132
2880
  }
2133
- const summary = readCache(join4(deps.cwd, ".calllint", "last-scan.json"));
2881
+ const summary = readCache(join5(deps.cwd, ".calllint", "last-scan.json"));
2134
2882
  if (!summary) {
2135
2883
  return {
2136
2884
  stdout: "",
@@ -2152,13 +2900,13 @@ function explainCommand(args, deps) {
2152
2900
  }
2153
2901
 
2154
2902
  // src/commands/policy.ts
2155
- import { existsSync as existsSync3, writeFileSync as writeFileSync2 } from "node:fs";
2156
- import { join as join5 } from "node:path";
2903
+ import { existsSync as existsSync5, writeFileSync as writeFileSync2 } from "node:fs";
2904
+ import { join as join6 } from "node:path";
2157
2905
  function policyCommand(args, deps) {
2158
2906
  const sub = args.positionals[0];
2159
2907
  if (sub === "init") {
2160
- const path = join5(deps.cwd, "calllint.policy.json");
2161
- if (existsSync3(path) && !flagBool(args.flags, "force")) {
2908
+ const path = join6(deps.cwd, "calllint.policy.json");
2909
+ if (existsSync5(path) && !flagBool(args.flags, "force")) {
2162
2910
  return {
2163
2911
  stdout: "",
2164
2912
  stderr: `${path} already exists. Use --force to overwrite.`,
@@ -2188,7 +2936,7 @@ function policyCommand(args, deps) {
2188
2936
  }
2189
2937
 
2190
2938
  // src/commands/verify.ts
2191
- import { join as join6 } from "node:path";
2939
+ import { join as join7 } from "node:path";
2192
2940
  function loadPolicy(args) {
2193
2941
  try {
2194
2942
  return loadPolicyOrDefault(flagStr(args.flags, "policy"));
@@ -2197,7 +2945,7 @@ function loadPolicy(args) {
2197
2945
  }
2198
2946
  }
2199
2947
  function baselinePathFor(args, cwd) {
2200
- return flagStr(args.flags, "baseline") ?? join6(cwd, ".calllint", "baseline.json");
2948
+ return flagStr(args.flags, "baseline") ?? join7(cwd, ".calllint", "baseline.json");
2201
2949
  }
2202
2950
  function baselineCommand(args, deps) {
2203
2951
  const policy = loadPolicy(args);
@@ -2283,6 +3031,15 @@ function run(argv, deps) {
2283
3031
  now: deps.now,
2284
3032
  generatedAt: deps.generatedAt,
2285
3033
  writeCacheFile: deps.writeCacheFile,
3034
+ online: deps.online,
3035
+ getChangedFilesDiff: deps.getChangedFilesDiff
3036
+ });
3037
+ case "diagnostics":
3038
+ return diagnosticsCommand(args, {
3039
+ cwd: deps.cwd,
3040
+ readStdin: deps.readStdin,
3041
+ now: deps.now,
3042
+ generatedAt: deps.generatedAt,
2286
3043
  online: deps.online
2287
3044
  });
2288
3045
  case "baseline":
@@ -2519,6 +3276,7 @@ function shouldBreathe(argv, deps = {}) {
2519
3276
  const { flags } = parseArgs(argv);
2520
3277
  if (flagBool(flags, "json")) return false;
2521
3278
  if (flagBool(flags, "sarif")) return false;
3279
+ if (flagBool(flags, "markdown")) return false;
2522
3280
  if (flagBool(flags, "html")) return false;
2523
3281
  if (flagBool(flags, "compact")) return false;
2524
3282
  if (flagBool(flags, "no-color")) return false;
@@ -2548,7 +3306,18 @@ async function breathe(argv, deps = {}) {
2548
3306
  // src/index.ts
2549
3307
  function readStdin() {
2550
3308
  try {
2551
- return readFileSync4(0, "utf8");
3309
+ return readFileSync6(0, "utf8");
3310
+ } catch {
3311
+ return "";
3312
+ }
3313
+ }
3314
+ function gitChangedFiles(cwd) {
3315
+ try {
3316
+ return execFileSync("git", ["diff", "--name-only", "HEAD"], {
3317
+ cwd,
3318
+ encoding: "utf8",
3319
+ stdio: ["ignore", "pipe", "ignore"]
3320
+ });
2552
3321
  } catch {
2553
3322
  return "";
2554
3323
  }
@@ -2585,7 +3354,8 @@ async function main() {
2585
3354
  readStdin,
2586
3355
  now,
2587
3356
  generatedAt,
2588
- online
3357
+ online,
3358
+ getChangedFilesDiff: () => gitChangedFiles(process.cwd())
2589
3359
  });
2590
3360
  if (result.stdout) process.stdout.write(result.stdout + "\n");
2591
3361
  if (result.stderr) process.stderr.write(result.stderr + "\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "calllint",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Evidence-backed security verdicts for MCP servers and agent tools. Lint agent tool-call risk before tools run — SAFE / REVIEW / BLOCK / UNKNOWN, with evidence. Never executes the server it judges.",
5
5
  "keywords": [
6
6
  "mcp",