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.
Files changed (2) hide show
  1. package/dist/index.js +195 -18
  2. 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 readFileSync5 } from "node:fs";
4
+ import { readFileSync as readFileSync6 } from "node:fs";
5
+ import { execFileSync } from "node:child_process";
5
6
 
6
7
  // src/args.ts
7
8
  var EXIT = {
@@ -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 existsSync3, readFileSync as readFileSync4, statSync } from "node:fs";
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 (!existsSync3(dir)) return surfaces;
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(resolve(deps.cwd, surfaceDir)) : void 0;
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 style = flagBool(args.flags, "no-emoji") ? NO_EMOJI_STYLE : DEFAULT_STYLE;
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 existsSync4, writeFileSync as writeFileSync2 } from "node:fs";
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 (existsSync4(path) && !flagBool(args.flags, "force")) {
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 readFileSync5(0, "utf8");
3309
+ return readFileSync6(0, "utf8");
3310
+ } catch {
3311
+ return "";
3312
+ }
3313
+ }
3314
+ function gitChangedFiles(cwd) {
3315
+ try {
3316
+ return execFileSync("git", ["diff", "--name-only", "HEAD"], {
3317
+ cwd,
3318
+ encoding: "utf8",
3319
+ stdio: ["ignore", "pipe", "ignore"]
3320
+ });
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.4.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",