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.
- package/README.md +1 -0
- package/dist/index.js +806 -36
- 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
|
|
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
|
|
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/
|
|
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
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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,
|
|
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"))
|
|
2114
|
-
|
|
2115
|
-
else
|
|
2116
|
-
|
|
2117
|
-
|
|
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
|
|
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(
|
|
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
|
|
2156
|
-
import { join as
|
|
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 =
|
|
2161
|
-
if (
|
|
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
|
|
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") ??
|
|
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
|
|
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
|
+
"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",
|