calllint 0.3.0 → 0.4.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 +622 -29
  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,7 @@
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 readFileSync5 } from "node:fs";
5
5
 
6
6
  // src/args.ts
7
7
  var EXIT = {
@@ -57,6 +57,7 @@ USAGE
57
57
 
58
58
  COMMANDS
59
59
  scan [target] Scan an MCP config file, or npm:<pkg> / github:<owner/repo>
60
+ diagnostics [target] Emit editor/agent-host diagnostics JSON (calllint.diagnostics.v0)
60
61
  baseline [target] Record the approved risk surface as a baseline
61
62
  verify [target] Compare a fresh scan against the baseline (drift / rug-pull)
62
63
  explain <server> Explain the verdict for one server from the last scan
@@ -89,6 +90,7 @@ EXAMPLES
89
90
  calllint scan .cursor/mcp.json
90
91
  cat .cursor/mcp.json | calllint scan --stdin --json
91
92
  calllint scan ./mcp.json --ci --no-emoji
93
+ calllint diagnostics ./mcp.json --json
92
94
  calllint scan npm:mcp-weather@1.0.0
93
95
  calllint scan github:owner/repo --online
94
96
  calllint baseline ./mcp.json
@@ -100,7 +102,7 @@ function helpCommand() {
100
102
  }
101
103
 
102
104
  // src/commands/scan.ts
103
- import { join as join3 } from "node:path";
105
+ import { join as join4, resolve } from "node:path";
104
106
 
105
107
  // ../../packages/policy/src/defaultPolicy.ts
106
108
  function defaultPolicy() {
@@ -267,7 +269,8 @@ function resolveScanOptions(opts) {
267
269
  policy: opts?.policy ?? defaultPolicy(),
268
270
  now: opts?.now ?? 0,
269
271
  generatedAt: opts?.generatedAt ?? "1970-01-01T00:00:00.000Z",
270
- extraFindings: opts?.extraFindings ?? {}
272
+ extraFindings: opts?.extraFindings ?? {},
273
+ surfaces: opts?.surfaces ?? []
271
274
  };
272
275
  }
273
276
 
@@ -534,6 +537,56 @@ function looksLikeBroadPath(arg) {
534
537
  if (/^[A-Za-z]:\\Users\\[^\\]+\\?$/.test(arg)) return true;
535
538
  return false;
536
539
  }
540
+ function looksLikeHostPath(s) {
541
+ if (!s) return false;
542
+ return s.startsWith("/") || // POSIX absolute (incl. /Users, /home, /etc, ...)
543
+ s.startsWith("~") || // home
544
+ s.startsWith(".") || // workspace-relative (handled as not-broad by isWorkspaceScoped/below)
545
+ /^[A-Za-z]:[\\/]/.test(s) || // Windows drive, C:\ or C:/
546
+ s.startsWith("$") || // $HOME / ${HOME}
547
+ s.startsWith("%");
548
+ }
549
+ function extractDockerHostPaths(args) {
550
+ const hosts = [];
551
+ for (let i = 0; i < args.length; i += 1) {
552
+ const arg = args[i];
553
+ if (arg === "--mount" || arg.startsWith("--mount=")) {
554
+ const csv = arg === "--mount" ? args[i + 1] ?? "" : arg.slice("--mount=".length);
555
+ if (arg === "--mount") i += 1;
556
+ const fields = /* @__PURE__ */ new Map();
557
+ for (const part of csv.split(",")) {
558
+ const eq = part.indexOf("=");
559
+ if (eq === -1) continue;
560
+ fields.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());
561
+ }
562
+ const type = fields.get("type") ?? "volume";
563
+ if (type !== "bind") continue;
564
+ const src = fields.get("source") ?? fields.get("src");
565
+ if (src && looksLikeHostPath(src)) hosts.push(src);
566
+ continue;
567
+ }
568
+ let spec;
569
+ if (arg === "-v" || arg === "--volume") {
570
+ spec = args[i + 1];
571
+ i += 1;
572
+ } else if (arg.startsWith("-v=")) {
573
+ spec = arg.slice("-v=".length);
574
+ } else if (arg.startsWith("--volume=")) {
575
+ spec = arg.slice("--volume=".length);
576
+ }
577
+ if (spec === void 0) continue;
578
+ const host = dockerVolumeHostSide(spec);
579
+ if (host && looksLikeHostPath(host)) hosts.push(host);
580
+ }
581
+ return hosts;
582
+ }
583
+ function dockerVolumeHostSide(spec) {
584
+ const winDrive = /^[A-Za-z]:[\\/]/.test(spec);
585
+ const searchFrom = winDrive ? 2 : 0;
586
+ const sep = spec.indexOf(":", searchFrom);
587
+ if (sep === -1) return void 0;
588
+ return spec.slice(0, sep);
589
+ }
537
590
  function detectBroadFilesystemPath(ctx) {
538
591
  const { server } = ctx;
539
592
  const evidence = [];
@@ -547,6 +600,18 @@ function detectBroadFilesystemPath(ctx) {
547
600
  });
548
601
  }
549
602
  }
603
+ if ((server.command ?? "").toLowerCase() === "docker") {
604
+ for (const host of extractDockerHostPaths(server.args)) {
605
+ if (looksLikeBroadPath(host)) {
606
+ evidence.push({
607
+ type: "config",
608
+ path: server.sourceConfigPath,
609
+ key: "args",
610
+ value: host
611
+ });
612
+ }
613
+ }
614
+ }
550
615
  if (evidence.length === 0) return [];
551
616
  return [
552
617
  {
@@ -727,7 +792,7 @@ function detectUnknownRemote(ctx) {
727
792
  ];
728
793
  }
729
794
 
730
- // ../../packages/static-analyzer/src/detectors/promptPoisoning.ts
795
+ // ../../packages/static-analyzer/src/promptScan.ts
731
796
  var POISON_PATTERNS = [
732
797
  "ignore previous instructions",
733
798
  "ignore all previous instructions",
@@ -749,23 +814,69 @@ var POISON_PATTERNS = [
749
814
  "override the system prompt",
750
815
  "you are now"
751
816
  ];
752
- function scanText(text, source) {
817
+ function findPoisonPhrases(text) {
753
818
  if (!text) return [];
754
819
  const lower = text.toLowerCase();
755
820
  const hits = [];
756
821
  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
- });
822
+ if (lower.includes(pattern)) hits.push(pattern);
823
+ }
824
+ return hits;
825
+ }
826
+ var INVISIBLE_CODE_POINTS = [8203, 8204, 8205, 8288, 65279];
827
+ var BIDI_RANGES = [
828
+ [8234, 8238],
829
+ [8294, 8297]
830
+ ];
831
+ var TAG_RANGE = [917504, 917631];
832
+ function hasInvisible(text) {
833
+ for (const ch of text) {
834
+ if (INVISIBLE_CODE_POINTS.includes(ch.codePointAt(0))) return true;
835
+ }
836
+ return false;
837
+ }
838
+ function inAnyRange(text, ranges) {
839
+ for (const ch of text) {
840
+ const cp = ch.codePointAt(0);
841
+ for (const [lo, hi] of ranges) {
842
+ if (cp >= lo && cp <= hi) return true;
767
843
  }
768
844
  }
845
+ return false;
846
+ }
847
+ function findHiddenContent(text) {
848
+ if (!text) return [];
849
+ const categories = [];
850
+ if (hasInvisible(text)) categories.push("zero-width or invisible characters");
851
+ if (inAnyRange(text, BIDI_RANGES)) {
852
+ categories.push("Unicode bidirectional override controls");
853
+ }
854
+ if (inAnyRange(text, [TAG_RANGE])) {
855
+ categories.push("invisible tag-character ASCII smuggling");
856
+ }
857
+ if (/<!--[\s\S]*?-->/.test(text)) categories.push("embedded HTML/XML comment");
858
+ return categories;
859
+ }
860
+ function poisonEvidence(pattern, source) {
861
+ return { type: source.type, path: source.path, key: source.key, snippet: pattern };
862
+ }
863
+ function hiddenEvidence(category, source) {
864
+ return { type: source.type, path: source.path, key: source.key, snippet: category };
865
+ }
866
+
867
+ // ../../packages/static-analyzer/src/detectors/promptPoisoning.ts
868
+ function scanText(text, source) {
869
+ const hits = [];
870
+ for (const pattern of findPoisonPhrases(text)) {
871
+ hits.push({
872
+ pattern,
873
+ evidence: poisonEvidence(pattern, {
874
+ type: "tool-metadata",
875
+ path: source.path,
876
+ key: source.key
877
+ })
878
+ });
879
+ }
769
880
  return hits;
770
881
  }
771
882
  function detectPromptPoisoning(ctx) {
@@ -1009,6 +1120,93 @@ function detectFinancialAction(ctx) {
1009
1120
  return findings;
1010
1121
  }
1011
1122
 
1123
+ // ../../packages/static-analyzer/src/detectors/unverifiedLocalSource.ts
1124
+ function localSubject(args) {
1125
+ for (const a of args) {
1126
+ if (a === "-y" || a === "--yes" || a.startsWith("-")) continue;
1127
+ return a;
1128
+ }
1129
+ return void 0;
1130
+ }
1131
+ function detectUnverifiedLocalSource(ctx) {
1132
+ const { binding, server } = ctx;
1133
+ if (!binding.sourceKnown) return [];
1134
+ if (!binding.runtimeExecutable) return [];
1135
+ if (binding.runtimeKind === "docker") return [];
1136
+ if (binding.packageName) return [];
1137
+ const command = binding.declaredCommand ?? server.command;
1138
+ if (!command) return [];
1139
+ const subject = localSubject(binding.declaredArgs);
1140
+ const display = subject ? `${command} ${subject}` : command;
1141
+ const evidence = [
1142
+ {
1143
+ type: "runtime-binding",
1144
+ path: server.sourceConfigPath,
1145
+ key: "command",
1146
+ value: display
1147
+ }
1148
+ ];
1149
+ return [
1150
+ {
1151
+ id: "exec.unverified-local-source",
1152
+ title: "Runs an unverified local executable",
1153
+ severity: "medium",
1154
+ blocker: false,
1155
+ symbol: "EXEC",
1156
+ riskClass: "S2",
1157
+ mode: "OBSERVED",
1158
+ confidence: "medium",
1159
+ detectionMethod: "runtime-binding",
1160
+ evidence,
1161
+ 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.",
1162
+ 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.",
1163
+ 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."
1164
+ }
1165
+ ];
1166
+ }
1167
+
1168
+ // ../../packages/static-analyzer/src/detectors/hiddenInstructions.ts
1169
+ function scanHidden(text, key, path) {
1170
+ const out = [];
1171
+ for (const category of findHiddenContent(text)) {
1172
+ out.push(hiddenEvidence(category, { type: "tool-metadata", path, key }));
1173
+ }
1174
+ return out;
1175
+ }
1176
+ function detectHiddenInstructions(ctx) {
1177
+ const { server } = ctx;
1178
+ const evidence = [];
1179
+ evidence.push(...scanHidden(server.instructions, "instructions", server.sourceConfigPath));
1180
+ for (const tool of server.providedTools) {
1181
+ const base = tool.name ? `tools.${tool.name}` : "tools";
1182
+ evidence.push(...scanHidden(tool.name, `${base}.name`, server.sourceConfigPath));
1183
+ evidence.push(
1184
+ ...scanHidden(tool.description, `${base}.description`, server.sourceConfigPath)
1185
+ );
1186
+ evidence.push(
1187
+ ...scanHidden(tool.inputSchemaText, `${base}.inputSchema`, server.sourceConfigPath)
1188
+ );
1189
+ }
1190
+ if (evidence.length === 0) return [];
1191
+ return [
1192
+ {
1193
+ id: "prompt.hidden-instructions",
1194
+ title: "Hidden or obfuscated content in model-visible metadata",
1195
+ severity: "medium",
1196
+ blocker: false,
1197
+ symbol: "PROMPT",
1198
+ riskClass: "S2",
1199
+ mode: "OBSERVED",
1200
+ confidence: "medium",
1201
+ detectionMethod: "tool-metadata",
1202
+ evidence,
1203
+ 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.",
1204
+ 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.",
1205
+ 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."
1206
+ }
1207
+ ];
1208
+ }
1209
+
1012
1210
  // ../../packages/static-analyzer/src/analyzeServerConfig.ts
1013
1211
  var DETECTORS = [
1014
1212
  detectBroadFilesystemPath,
@@ -1018,7 +1216,9 @@ var DETECTORS = [
1018
1216
  detectUnpinnedPackage,
1019
1217
  detectUnknownRemote,
1020
1218
  detectExternalMutation,
1021
- detectFinancialAction
1219
+ detectFinancialAction,
1220
+ detectUnverifiedLocalSource,
1221
+ detectHiddenInstructions
1022
1222
  ];
1023
1223
  function analyzeServerConfig(server) {
1024
1224
  const binding = resolveRuntimeBinding(server);
@@ -1030,6 +1230,41 @@ function analyzeServerConfig(server) {
1030
1230
  return findings;
1031
1231
  }
1032
1232
 
1233
+ // ../../packages/static-analyzer/src/documentSurface.ts
1234
+ function analyzeDocumentSurfaces(surfaces) {
1235
+ const evidence = [];
1236
+ for (const surface of surfaces) {
1237
+ for (const pattern of findPoisonPhrases(surface.text)) {
1238
+ evidence.push(
1239
+ poisonEvidence(pattern, { type: "source", path: surface.path, key: surface.kind })
1240
+ );
1241
+ }
1242
+ for (const category of findHiddenContent(surface.text)) {
1243
+ evidence.push(
1244
+ hiddenEvidence(category, { type: "source", path: surface.path, key: surface.kind })
1245
+ );
1246
+ }
1247
+ }
1248
+ if (evidence.length === 0) return [];
1249
+ return [
1250
+ {
1251
+ id: "prompt.surface-instructions",
1252
+ title: "Model-directed or hidden content in a project document",
1253
+ severity: "medium",
1254
+ blocker: false,
1255
+ symbol: "PROMPT",
1256
+ riskClass: "S2",
1257
+ mode: "OBSERVED",
1258
+ confidence: "medium",
1259
+ detectionMethod: "source-text",
1260
+ evidence,
1261
+ 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.",
1262
+ fix: "Remove model-directed instructions and hidden/obfuscated characters from project documents; keep their visible text equal to their intent.",
1263
+ 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."
1264
+ }
1265
+ ];
1266
+ }
1267
+
1033
1268
  // ../../packages/risk-engine/src/computeRiskClass.ts
1034
1269
  function computeRiskClass(findings, binding) {
1035
1270
  const classes = findings.map((f) => f.riskClass);
@@ -1387,6 +1622,126 @@ function normalizeMcpServers(root, sourceConfigPath) {
1387
1622
 
1388
1623
  // ../../packages/config-parser/src/parseConfig.ts
1389
1624
  import { basename } from "node:path";
1625
+
1626
+ // ../../packages/config-parser/src/positionIndex.ts
1627
+ function peek(c) {
1628
+ return c.text[c.i] ?? "";
1629
+ }
1630
+ function advance(c) {
1631
+ const ch = c.text[c.i] ?? "";
1632
+ c.i++;
1633
+ if (ch === "\n") {
1634
+ c.line++;
1635
+ c.col = 1;
1636
+ } else {
1637
+ c.col++;
1638
+ }
1639
+ return ch;
1640
+ }
1641
+ function skipWhitespace(c) {
1642
+ while (c.i < c.text.length) {
1643
+ const ch = peek(c);
1644
+ if (ch === " " || ch === " " || ch === "\n" || ch === "\r") advance(c);
1645
+ else break;
1646
+ }
1647
+ }
1648
+ function readString(c) {
1649
+ advance(c);
1650
+ let out = "";
1651
+ while (c.i < c.text.length) {
1652
+ const ch = advance(c);
1653
+ if (ch === "\\") {
1654
+ const esc2 = advance(c);
1655
+ if (esc2 === "n") out += "\n";
1656
+ else if (esc2 === "t") out += " ";
1657
+ else if (esc2 === "r") out += "\r";
1658
+ else if (esc2 === "u") {
1659
+ let hex = "";
1660
+ for (let k = 0; k < 4; k++) hex += advance(c);
1661
+ const code = Number.parseInt(hex, 16);
1662
+ out += Number.isNaN(code) ? "" : String.fromCharCode(code);
1663
+ } else out += esc2;
1664
+ } else if (ch === '"') {
1665
+ break;
1666
+ } else {
1667
+ out += ch;
1668
+ }
1669
+ }
1670
+ return out;
1671
+ }
1672
+ function skipValue(c, index, pathPrefix) {
1673
+ skipWhitespace(c);
1674
+ const ch = peek(c);
1675
+ if (ch === "{") {
1676
+ scanObject(c, index, pathPrefix);
1677
+ } else if (ch === "[") {
1678
+ scanArray(c, index, pathPrefix);
1679
+ } else if (ch === '"') {
1680
+ readString(c);
1681
+ } else {
1682
+ while (c.i < c.text.length) {
1683
+ const n = peek(c);
1684
+ if (n === "," || n === "}" || n === "]" || n === " " || n === " " || n === "\n" || n === "\r") break;
1685
+ advance(c);
1686
+ }
1687
+ }
1688
+ }
1689
+ function scanArray(c, index, pathPrefix) {
1690
+ advance(c);
1691
+ for (; ; ) {
1692
+ skipWhitespace(c);
1693
+ const ch = peek(c);
1694
+ if (ch === "]" || ch === "") {
1695
+ if (ch === "]") advance(c);
1696
+ return;
1697
+ }
1698
+ if (ch === ",") {
1699
+ advance(c);
1700
+ continue;
1701
+ }
1702
+ skipValue(c, index, pathPrefix);
1703
+ }
1704
+ }
1705
+ function scanObject(c, index, pathPrefix) {
1706
+ advance(c);
1707
+ for (; ; ) {
1708
+ skipWhitespace(c);
1709
+ const ch = peek(c);
1710
+ if (ch === "}" || ch === "") {
1711
+ if (ch === "}") advance(c);
1712
+ return;
1713
+ }
1714
+ if (ch === ",") {
1715
+ advance(c);
1716
+ continue;
1717
+ }
1718
+ if (ch !== '"') {
1719
+ advance(c);
1720
+ continue;
1721
+ }
1722
+ const keyLine = c.line;
1723
+ const keyCol = c.col;
1724
+ const key = readString(c);
1725
+ const path = pathPrefix ? `${pathPrefix}.${key}` : key;
1726
+ if (!(path in index)) index[path] = { line: keyLine, column: keyCol };
1727
+ skipWhitespace(c);
1728
+ if (peek(c) === ":") advance(c);
1729
+ skipValue(c, index, path);
1730
+ }
1731
+ }
1732
+ function buildPositionIndex(text) {
1733
+ const index = {};
1734
+ const c = { text, i: 0, line: 1, col: 1 };
1735
+ try {
1736
+ skipWhitespace(c);
1737
+ if (peek(c) === "{") scanObject(c, index, "");
1738
+ else if (peek(c) === "[") scanArray(c, index, "");
1739
+ } catch {
1740
+ }
1741
+ return index;
1742
+ }
1743
+
1744
+ // ../../packages/config-parser/src/parseConfig.ts
1390
1745
  function kindForPath(path) {
1391
1746
  const base = basename(path).toLowerCase();
1392
1747
  if (base.includes("settings")) return "claude-settings";
@@ -1399,10 +1754,84 @@ function parseConfigText(text, configPath = "<inline>") {
1399
1754
  configPath,
1400
1755
  kind: configPath === "<inline>" ? "inline" : kindForPath(configPath),
1401
1756
  servers: normalizeMcpServers(root, configPath),
1402
- root
1757
+ root,
1758
+ positions: buildPositionIndex(text)
1759
+ };
1760
+ }
1761
+
1762
+ // ../../packages/core/src/scanSurfaces.ts
1763
+ function scanDocumentSurfaces(surfaces, configPath, generatedAt) {
1764
+ if (surfaces.length === 0) return void 0;
1765
+ const findings = analyzeDocumentSurfaces(surfaces);
1766
+ if (findings.length === 0) return void 0;
1767
+ const truncated = surfaces.some((s) => s.truncated);
1768
+ return {
1769
+ schemaVersion: "calllint.report.v0",
1770
+ reportKind: "single-target",
1771
+ target: {
1772
+ name: "project-docs",
1773
+ kind: "project-docs",
1774
+ configPath
1775
+ },
1776
+ verdict: "REVIEW",
1777
+ publicVerdictLabel: VERDICT_PUBLIC_LABEL.REVIEW,
1778
+ riskClass: "S2",
1779
+ symbols: ["PROMPT"],
1780
+ confidence: "medium",
1781
+ reproducibility: {
1782
+ level: "HIGH",
1783
+ reasons: ["Static scan of local documents; deterministic given the same files."]
1784
+ },
1785
+ summary: "Project documents contain model-directed or hidden prompt-surface content; review the cited files.",
1786
+ observed: findings,
1787
+ inferred: [],
1788
+ findings,
1789
+ topFindings: findings,
1790
+ policy: {
1791
+ autonomousUse: "warn",
1792
+ manualApproval: "recommended",
1793
+ sandbox: "recommended"
1794
+ },
1795
+ fingerprints: {
1796
+ configHash: "",
1797
+ targetSpecHash: "",
1798
+ riskSurfaceHash: ""
1799
+ },
1800
+ diagnostics: truncated ? [
1801
+ {
1802
+ level: "info",
1803
+ code: "surface.truncated",
1804
+ message: "One or more project documents exceeded the scan size cap and were truncated."
1805
+ }
1806
+ ] : [],
1807
+ generatedAt
1403
1808
  };
1404
1809
  }
1405
1810
 
1811
+ // ../../packages/core/src/enrichPositions.ts
1812
+ function enrichEvidencePositions(reports, positions) {
1813
+ if (Object.keys(positions).length === 0) return;
1814
+ const prefixes = ["mcpServers", "servers", ""];
1815
+ for (const report of reports) {
1816
+ const server = report.target.name;
1817
+ for (const finding of report.findings) {
1818
+ for (const ev of finding.evidence) {
1819
+ if (ev.line !== void 0) continue;
1820
+ if (!ev.key) continue;
1821
+ for (const prefix of prefixes) {
1822
+ const path = prefix ? `${prefix}.${server}.${ev.key}` : `${server}.${ev.key}`;
1823
+ const pos = positions[path];
1824
+ if (pos) {
1825
+ ev.line = pos.line;
1826
+ ev.column = pos.column;
1827
+ break;
1828
+ }
1829
+ }
1830
+ }
1831
+ }
1832
+ }
1833
+ }
1834
+
1406
1835
  // ../../packages/core/src/scanConfig.ts
1407
1836
  function aggregate(configPath, reports, generatedAt) {
1408
1837
  const counts = { SAFE: 0, REVIEW: 0, BLOCK: 0, UNKNOWN: 0 };
@@ -1421,10 +1850,13 @@ function aggregate(configPath, reports, generatedAt) {
1421
1850
  };
1422
1851
  }
1423
1852
  function scanParsed(parsed, opts) {
1424
- const { generatedAt } = resolveScanOptions(opts);
1853
+ const { generatedAt, surfaces } = resolveScanOptions(opts);
1425
1854
  const reports = parsed.servers.map(
1426
1855
  (server) => scanServer({ server, targetKind: parsed.kind }, opts)
1427
1856
  );
1857
+ enrichEvidencePositions(reports, parsed.positions);
1858
+ const surfaceReport = scanDocumentSurfaces(surfaces, parsed.configPath, generatedAt);
1859
+ if (surfaceReport) reports.push(surfaceReport);
1428
1860
  return aggregate(parsed.configPath, reports, generatedAt);
1429
1861
  }
1430
1862
  function scanConfigText(text, configPath, opts) {
@@ -1872,6 +2304,60 @@ function renderSarif(summary) {
1872
2304
  return JSON.stringify(sarif, null, 2);
1873
2305
  }
1874
2306
 
2307
+ // ../../packages/report-renderer/src/renderDiagnostics.ts
2308
+ function verdictContributionFor(f) {
2309
+ if (f.blocker) return "blocker";
2310
+ return f.mode === "INFERRED" ? "inferred" : "review";
2311
+ }
2312
+ function keyPathFor(f) {
2313
+ const ev = f.evidence[0];
2314
+ if (!ev) return null;
2315
+ return ev.key ?? null;
2316
+ }
2317
+ function observedFor(f) {
2318
+ const ev = f.evidence[0];
2319
+ if (!ev) return null;
2320
+ return ev.value ?? ev.snippet ?? null;
2321
+ }
2322
+ function entryFromFinding(f, report, configFile) {
2323
+ const ev = f.evidence[0];
2324
+ return {
2325
+ ruleId: f.id,
2326
+ title: f.title,
2327
+ severity: f.severity,
2328
+ server: report.target.name,
2329
+ file: ev?.path ?? configFile,
2330
+ keyPath: keyPathFor(f),
2331
+ // Populated by the post-hoc position enrichment (config-parser position
2332
+ // index → core), when the evidence's config key maps to a source location;
2333
+ // null for evidence with no locatable source key (e.g. binding-derived).
2334
+ line: ev?.line ?? null,
2335
+ column: ev?.column ?? null,
2336
+ observed: observedFor(f),
2337
+ remediation: f.fix,
2338
+ mode: f.mode,
2339
+ confidence: f.confidence,
2340
+ verdictContribution: verdictContributionFor(f)
2341
+ };
2342
+ }
2343
+ function renderDiagnostics(summary) {
2344
+ const diagnostics = [];
2345
+ for (const report of summary.reports) {
2346
+ for (const f of report.findings) {
2347
+ diagnostics.push(entryFromFinding(f, report, summary.configPath));
2348
+ }
2349
+ }
2350
+ const out = {
2351
+ schemaVersion: "calllint.diagnostics.v0",
2352
+ verdict: summary.verdict,
2353
+ publicVerdictLabel: summary.publicVerdictLabel,
2354
+ file: summary.configPath,
2355
+ diagnostics,
2356
+ generatedAt: summary.generatedAt
2357
+ };
2358
+ return JSON.stringify(out, null, 2);
2359
+ }
2360
+
1875
2361
  // ../../packages/report-renderer/src/logoBase64.ts
1876
2362
  var LOGO_REPORT_BASE64 = "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";
1877
2363
 
@@ -2066,6 +2552,55 @@ function resolveConfigInput(args, deps) {
2066
2552
  return { text: readFileSync3(resolved, "utf8"), configPath: resolved };
2067
2553
  }
2068
2554
 
2555
+ // src/commands/surfaces.ts
2556
+ import { existsSync as existsSync3, readFileSync as readFileSync4, statSync } from "node:fs";
2557
+ import { join as join3 } from "node:path";
2558
+ var SURFACE_SIZE_CAP = 256 * 1024;
2559
+ var SURFACE_FILES = [
2560
+ { file: "README.md", kind: "readme" },
2561
+ { file: "SKILL.md", kind: "skill" },
2562
+ { file: "AGENTS.md", kind: "agents" }
2563
+ ];
2564
+ function readCapped(path) {
2565
+ try {
2566
+ const st = statSync(path);
2567
+ if (!st.isFile()) return void 0;
2568
+ const raw = readFileSync4(path, "utf8");
2569
+ if (raw.length > SURFACE_SIZE_CAP) {
2570
+ return { text: raw.slice(0, SURFACE_SIZE_CAP), truncated: true };
2571
+ }
2572
+ return { text: raw, truncated: false };
2573
+ } catch {
2574
+ return void 0;
2575
+ }
2576
+ }
2577
+ function readDocumentSurfaces(dir) {
2578
+ const surfaces = [];
2579
+ if (!existsSync3(dir)) return surfaces;
2580
+ for (const { file, kind } of SURFACE_FILES) {
2581
+ const got = readCapped(join3(dir, file));
2582
+ if (got) surfaces.push({ path: file, kind, text: got.text, truncated: got.truncated });
2583
+ }
2584
+ const pkgPath = join3(dir, "package.json");
2585
+ const pkgRaw = readCapped(pkgPath);
2586
+ if (pkgRaw) {
2587
+ try {
2588
+ const pkg = JSON.parse(pkgRaw.text);
2589
+ const description = pkg && typeof pkg === "object" && !Array.isArray(pkg) ? pkg.description : void 0;
2590
+ if (typeof description === "string") {
2591
+ surfaces.push({
2592
+ path: "package.json",
2593
+ kind: "package-description",
2594
+ text: description,
2595
+ truncated: pkgRaw.truncated
2596
+ });
2597
+ }
2598
+ } catch {
2599
+ }
2600
+ }
2601
+ return surfaces;
2602
+ }
2603
+
2069
2604
  // src/commands/scan.ts
2070
2605
  function scanCommand(args, deps) {
2071
2606
  const policyPath = flagStr(args.flags, "policy");
@@ -2084,13 +2619,16 @@ function scanCommand(args, deps) {
2084
2619
  return { stdout: "", stderr: input.error, exitCode: input.exitCode };
2085
2620
  }
2086
2621
  const { text, configPath } = input;
2622
+ const surfaceDir = flagStr(args.flags, "surface-dir");
2623
+ const surfaces = surfaceDir ? readDocumentSurfaces(resolve(deps.cwd, surfaceDir)) : void 0;
2087
2624
  let summary;
2088
2625
  try {
2089
2626
  summary = scanConfigText(text, configPath, {
2090
2627
  policy,
2091
2628
  now: deps.now,
2092
2629
  generatedAt: deps.generatedAt,
2093
- extraFindings: deps.online?.extraFindings
2630
+ extraFindings: deps.online?.extraFindings,
2631
+ surfaces
2094
2632
  });
2095
2633
  } catch (err) {
2096
2634
  if (err instanceof ConfigParseError) {
@@ -2104,7 +2642,7 @@ function scanCommand(args, deps) {
2104
2642
  }
2105
2643
  if (deps.writeCacheFile !== false) {
2106
2644
  try {
2107
- writeCache(summary, join3(deps.cwd, ".calllint", "last-scan.json"));
2645
+ writeCache(summary, join4(deps.cwd, ".calllint", "last-scan.json"));
2108
2646
  } catch {
2109
2647
  }
2110
2648
  }
@@ -2119,8 +2657,55 @@ function scanCommand(args, deps) {
2119
2657
  return { stdout, exitCode };
2120
2658
  }
2121
2659
 
2660
+ // src/commands/diagnostics.ts
2661
+ function diagnosticsCommand(args, deps) {
2662
+ if (!flagBool(args.flags, "json")) {
2663
+ return {
2664
+ stdout: "",
2665
+ stderr: "diagnostics v0 emits JSON only \u2014 pass --json (calllint.diagnostics.v0).",
2666
+ exitCode: EXIT.USAGE
2667
+ };
2668
+ }
2669
+ let policy;
2670
+ try {
2671
+ policy = loadPolicyOrDefault(flagStr(args.flags, "policy"));
2672
+ } catch (err) {
2673
+ return {
2674
+ stdout: "",
2675
+ stderr: err instanceof Error ? err.message : String(err),
2676
+ exitCode: EXIT.ERROR
2677
+ };
2678
+ }
2679
+ const input = deps.online?.inputOverride ?? resolveConfigInput(args, deps);
2680
+ if (isInputError(input)) {
2681
+ return { stdout: "", stderr: input.error, exitCode: input.exitCode };
2682
+ }
2683
+ const { text, configPath } = input;
2684
+ let summary;
2685
+ try {
2686
+ summary = scanConfigText(text, configPath, {
2687
+ policy,
2688
+ now: deps.now,
2689
+ generatedAt: deps.generatedAt,
2690
+ extraFindings: deps.online?.extraFindings
2691
+ });
2692
+ } catch (err) {
2693
+ if (err instanceof ConfigParseError) {
2694
+ return {
2695
+ stdout: "",
2696
+ stderr: `Parse error in ${configPath}: ${err.message}`,
2697
+ exitCode: EXIT.ERROR
2698
+ };
2699
+ }
2700
+ throw err;
2701
+ }
2702
+ const stdout = renderDiagnostics(summary);
2703
+ const exitCode = flagBool(args.flags, "ci") ? exitCodeFor(summary, policy) : EXIT.OK;
2704
+ return { stdout, exitCode };
2705
+ }
2706
+
2122
2707
  // src/commands/explain.ts
2123
- import { join as join4 } from "node:path";
2708
+ import { join as join5 } from "node:path";
2124
2709
  function explainCommand(args, deps) {
2125
2710
  const serverName = args.positionals[0];
2126
2711
  if (!serverName) {
@@ -2130,7 +2715,7 @@ function explainCommand(args, deps) {
2130
2715
  exitCode: EXIT.USAGE
2131
2716
  };
2132
2717
  }
2133
- const summary = readCache(join4(deps.cwd, ".calllint", "last-scan.json"));
2718
+ const summary = readCache(join5(deps.cwd, ".calllint", "last-scan.json"));
2134
2719
  if (!summary) {
2135
2720
  return {
2136
2721
  stdout: "",
@@ -2152,13 +2737,13 @@ function explainCommand(args, deps) {
2152
2737
  }
2153
2738
 
2154
2739
  // src/commands/policy.ts
2155
- import { existsSync as existsSync3, writeFileSync as writeFileSync2 } from "node:fs";
2156
- import { join as join5 } from "node:path";
2740
+ import { existsSync as existsSync4, writeFileSync as writeFileSync2 } from "node:fs";
2741
+ import { join as join6 } from "node:path";
2157
2742
  function policyCommand(args, deps) {
2158
2743
  const sub = args.positionals[0];
2159
2744
  if (sub === "init") {
2160
- const path = join5(deps.cwd, "calllint.policy.json");
2161
- if (existsSync3(path) && !flagBool(args.flags, "force")) {
2745
+ const path = join6(deps.cwd, "calllint.policy.json");
2746
+ if (existsSync4(path) && !flagBool(args.flags, "force")) {
2162
2747
  return {
2163
2748
  stdout: "",
2164
2749
  stderr: `${path} already exists. Use --force to overwrite.`,
@@ -2188,7 +2773,7 @@ function policyCommand(args, deps) {
2188
2773
  }
2189
2774
 
2190
2775
  // src/commands/verify.ts
2191
- import { join as join6 } from "node:path";
2776
+ import { join as join7 } from "node:path";
2192
2777
  function loadPolicy(args) {
2193
2778
  try {
2194
2779
  return loadPolicyOrDefault(flagStr(args.flags, "policy"));
@@ -2197,7 +2782,7 @@ function loadPolicy(args) {
2197
2782
  }
2198
2783
  }
2199
2784
  function baselinePathFor(args, cwd) {
2200
- return flagStr(args.flags, "baseline") ?? join6(cwd, ".calllint", "baseline.json");
2785
+ return flagStr(args.flags, "baseline") ?? join7(cwd, ".calllint", "baseline.json");
2201
2786
  }
2202
2787
  function baselineCommand(args, deps) {
2203
2788
  const policy = loadPolicy(args);
@@ -2285,6 +2870,14 @@ function run(argv, deps) {
2285
2870
  writeCacheFile: deps.writeCacheFile,
2286
2871
  online: deps.online
2287
2872
  });
2873
+ case "diagnostics":
2874
+ return diagnosticsCommand(args, {
2875
+ cwd: deps.cwd,
2876
+ readStdin: deps.readStdin,
2877
+ now: deps.now,
2878
+ generatedAt: deps.generatedAt,
2879
+ online: deps.online
2880
+ });
2288
2881
  case "baseline":
2289
2882
  return baselineCommand(args, {
2290
2883
  cwd: deps.cwd,
@@ -2548,7 +3141,7 @@ async function breathe(argv, deps = {}) {
2548
3141
  // src/index.ts
2549
3142
  function readStdin() {
2550
3143
  try {
2551
- return readFileSync4(0, "utf8");
3144
+ return readFileSync5(0, "utf8");
2552
3145
  } catch {
2553
3146
  return "";
2554
3147
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "calllint",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",