calllint 0.4.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/dist/index.js +195 -18
- package/package.json +1 -1
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 = {
|
|
@@ -71,10 +72,12 @@ TARGETS
|
|
|
71
72
|
github:<owner/repo>[@ref] A GitHub repo (requires --online)
|
|
72
73
|
|
|
73
74
|
SCAN OPTIONS
|
|
75
|
+
--changed Scan only the agent-tool configs changed in the git diff
|
|
74
76
|
--json Emit the ScanReport JSON (stable, emoji-free)
|
|
75
77
|
--compact One line per server
|
|
76
78
|
--no-emoji Plain-text symbols (good for CI logs)
|
|
77
79
|
--sarif Emit SARIF 2.1.0 (GitHub Code Scanning / CI)
|
|
80
|
+
--markdown Emit Markdown for PR comments / GitHub Step Summary
|
|
78
81
|
--html Emit a self-contained HTML report
|
|
79
82
|
--policy <file> Use a policy file (default: built-in defaults)
|
|
80
83
|
--stdin Read config JSON from stdin
|
|
@@ -88,6 +91,7 @@ VERIFY OPTIONS
|
|
|
88
91
|
|
|
89
92
|
EXAMPLES
|
|
90
93
|
calllint scan .cursor/mcp.json
|
|
94
|
+
calllint scan --changed --markdown
|
|
91
95
|
cat .cursor/mcp.json | calllint scan --stdin --json
|
|
92
96
|
calllint scan ./mcp.json --ci --no-emoji
|
|
93
97
|
calllint diagnostics ./mcp.json --json
|
|
@@ -102,7 +106,7 @@ function helpCommand() {
|
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
// src/commands/scan.ts
|
|
105
|
-
import { join as join4, resolve } from "node:path";
|
|
109
|
+
import { join as join4, resolve as resolve2 } from "node:path";
|
|
106
110
|
|
|
107
111
|
// ../../packages/policy/src/defaultPolicy.ts
|
|
108
112
|
function defaultPolicy() {
|
|
@@ -168,6 +172,12 @@ function validateOverride(o, index, issues) {
|
|
|
168
172
|
message: "must be a valid ISO timestamp (overrides must expire)"
|
|
169
173
|
});
|
|
170
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
|
+
}
|
|
171
181
|
const allow = Array.isArray(o.allow) ? o.allow : [];
|
|
172
182
|
const allowsDangerous = allow.some(
|
|
173
183
|
(s) => typeof s === "string" && DANGEROUS_SYMBOLS.has(s)
|
|
@@ -255,7 +265,7 @@ function applyPolicy(rawVerdict, serverName, blockingFindings, policy, now) {
|
|
|
255
265
|
return {
|
|
256
266
|
verdict: "REVIEW",
|
|
257
267
|
changed: true,
|
|
258
|
-
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}`
|
|
259
269
|
};
|
|
260
270
|
}
|
|
261
271
|
function shouldFailCi(verdict, policy) {
|
|
@@ -2304,6 +2314,92 @@ function renderSarif(summary) {
|
|
|
2304
2314
|
return JSON.stringify(sarif, null, 2);
|
|
2305
2315
|
}
|
|
2306
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
|
+
|
|
2307
2403
|
// ../../packages/report-renderer/src/renderDiagnostics.ts
|
|
2308
2404
|
function verdictContributionFor(f) {
|
|
2309
2405
|
if (f.blocker) return "blocker";
|
|
@@ -2552,8 +2648,25 @@ function resolveConfigInput(args, deps) {
|
|
|
2552
2648
|
return { text: readFileSync3(resolved, "utf8"), configPath: resolved };
|
|
2553
2649
|
}
|
|
2554
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
|
+
|
|
2555
2668
|
// src/commands/surfaces.ts
|
|
2556
|
-
import { existsSync as
|
|
2669
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, statSync } from "node:fs";
|
|
2557
2670
|
import { join as join3 } from "node:path";
|
|
2558
2671
|
var SURFACE_SIZE_CAP = 256 * 1024;
|
|
2559
2672
|
var SURFACE_FILES = [
|
|
@@ -2576,7 +2689,7 @@ function readCapped(path) {
|
|
|
2576
2689
|
}
|
|
2577
2690
|
function readDocumentSurfaces(dir) {
|
|
2578
2691
|
const surfaces = [];
|
|
2579
|
-
if (!
|
|
2692
|
+
if (!existsSync4(dir)) return surfaces;
|
|
2580
2693
|
for (const { file, kind } of SURFACE_FILES) {
|
|
2581
2694
|
const got = readCapped(join3(dir, file));
|
|
2582
2695
|
if (got) surfaces.push({ path: file, kind, text: got.text, truncated: got.truncated });
|
|
@@ -2602,7 +2715,11 @@ function readDocumentSurfaces(dir) {
|
|
|
2602
2715
|
}
|
|
2603
2716
|
|
|
2604
2717
|
// src/commands/scan.ts
|
|
2718
|
+
import { readFileSync as readFileSync5 } from "node:fs";
|
|
2605
2719
|
function scanCommand(args, deps) {
|
|
2720
|
+
if (flagBool(args.flags, "changed")) {
|
|
2721
|
+
return scanChangedCommand(args, deps);
|
|
2722
|
+
}
|
|
2606
2723
|
const policyPath = flagStr(args.flags, "policy");
|
|
2607
2724
|
let policy;
|
|
2608
2725
|
try {
|
|
@@ -2619,8 +2736,11 @@ function scanCommand(args, deps) {
|
|
|
2619
2736
|
return { stdout: "", stderr: input.error, exitCode: input.exitCode };
|
|
2620
2737
|
}
|
|
2621
2738
|
const { text, configPath } = input;
|
|
2739
|
+
return scanOneConfig(text, configPath, policy, args, deps);
|
|
2740
|
+
}
|
|
2741
|
+
function scanOneConfig(text, configPath, policy, args, deps) {
|
|
2622
2742
|
const surfaceDir = flagStr(args.flags, "surface-dir");
|
|
2623
|
-
const surfaces = surfaceDir ? readDocumentSurfaces(
|
|
2743
|
+
const surfaces = surfaceDir ? readDocumentSurfaces(resolve2(deps.cwd, surfaceDir)) : void 0;
|
|
2624
2744
|
let summary;
|
|
2625
2745
|
try {
|
|
2626
2746
|
summary = scanConfigText(text, configPath, {
|
|
@@ -2646,16 +2766,59 @@ function scanCommand(args, deps) {
|
|
|
2646
2766
|
} catch {
|
|
2647
2767
|
}
|
|
2648
2768
|
}
|
|
2649
|
-
const
|
|
2650
|
-
let stdout;
|
|
2651
|
-
if (flagBool(args.flags, "json")) stdout = renderJson(summary);
|
|
2652
|
-
else if (flagBool(args.flags, "sarif")) stdout = renderSarif(summary);
|
|
2653
|
-
else if (flagBool(args.flags, "html")) stdout = renderHtml(summary);
|
|
2654
|
-
else if (flagBool(args.flags, "compact")) stdout = renderCompact(summary, style);
|
|
2655
|
-
else stdout = renderTerminal(summary, style);
|
|
2769
|
+
const stdout = renderSummary(summary, args);
|
|
2656
2770
|
const exitCode = flagBool(args.flags, "ci") ? exitCodeFor(summary, policy) : EXIT.OK;
|
|
2657
2771
|
return { stdout, exitCode };
|
|
2658
2772
|
}
|
|
2773
|
+
function renderSummary(summary, args) {
|
|
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);
|
|
2813
|
+
let stdout;
|
|
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
|
+
}
|
|
2659
2822
|
|
|
2660
2823
|
// src/commands/diagnostics.ts
|
|
2661
2824
|
function diagnosticsCommand(args, deps) {
|
|
@@ -2737,13 +2900,13 @@ function explainCommand(args, deps) {
|
|
|
2737
2900
|
}
|
|
2738
2901
|
|
|
2739
2902
|
// src/commands/policy.ts
|
|
2740
|
-
import { existsSync as
|
|
2903
|
+
import { existsSync as existsSync5, writeFileSync as writeFileSync2 } from "node:fs";
|
|
2741
2904
|
import { join as join6 } from "node:path";
|
|
2742
2905
|
function policyCommand(args, deps) {
|
|
2743
2906
|
const sub = args.positionals[0];
|
|
2744
2907
|
if (sub === "init") {
|
|
2745
2908
|
const path = join6(deps.cwd, "calllint.policy.json");
|
|
2746
|
-
if (
|
|
2909
|
+
if (existsSync5(path) && !flagBool(args.flags, "force")) {
|
|
2747
2910
|
return {
|
|
2748
2911
|
stdout: "",
|
|
2749
2912
|
stderr: `${path} already exists. Use --force to overwrite.`,
|
|
@@ -2868,7 +3031,8 @@ function run(argv, deps) {
|
|
|
2868
3031
|
now: deps.now,
|
|
2869
3032
|
generatedAt: deps.generatedAt,
|
|
2870
3033
|
writeCacheFile: deps.writeCacheFile,
|
|
2871
|
-
online: deps.online
|
|
3034
|
+
online: deps.online,
|
|
3035
|
+
getChangedFilesDiff: deps.getChangedFilesDiff
|
|
2872
3036
|
});
|
|
2873
3037
|
case "diagnostics":
|
|
2874
3038
|
return diagnosticsCommand(args, {
|
|
@@ -3112,6 +3276,7 @@ function shouldBreathe(argv, deps = {}) {
|
|
|
3112
3276
|
const { flags } = parseArgs(argv);
|
|
3113
3277
|
if (flagBool(flags, "json")) return false;
|
|
3114
3278
|
if (flagBool(flags, "sarif")) return false;
|
|
3279
|
+
if (flagBool(flags, "markdown")) return false;
|
|
3115
3280
|
if (flagBool(flags, "html")) return false;
|
|
3116
3281
|
if (flagBool(flags, "compact")) return false;
|
|
3117
3282
|
if (flagBool(flags, "no-color")) return false;
|
|
@@ -3141,7 +3306,18 @@ async function breathe(argv, deps = {}) {
|
|
|
3141
3306
|
// src/index.ts
|
|
3142
3307
|
function readStdin() {
|
|
3143
3308
|
try {
|
|
3144
|
-
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
|
+
});
|
|
3145
3321
|
} catch {
|
|
3146
3322
|
return "";
|
|
3147
3323
|
}
|
|
@@ -3178,7 +3354,8 @@ async function main() {
|
|
|
3178
3354
|
readStdin,
|
|
3179
3355
|
now,
|
|
3180
3356
|
generatedAt,
|
|
3181
|
-
online
|
|
3357
|
+
online,
|
|
3358
|
+
getChangedFilesDiff: () => gitChangedFiles(process.cwd())
|
|
3182
3359
|
});
|
|
3183
3360
|
if (result.stdout) process.stdout.write(result.stdout + "\n");
|
|
3184
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",
|