calllint 0.5.0 → 0.7.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 +1286 -48
- package/package.json +1 -1
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
|
|
4
|
+
import { readFileSync as readFileSync8 } from "node:fs";
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
6
6
|
|
|
7
7
|
// src/args.ts
|
|
@@ -57,15 +57,27 @@ USAGE
|
|
|
57
57
|
calllint <command> [options]
|
|
58
58
|
|
|
59
59
|
COMMANDS
|
|
60
|
-
|
|
60
|
+
check [target] Compact safety decision for an MCP config or install snippet
|
|
61
|
+
scan-all Scan every agent-tool surface in the repo (compact table)
|
|
62
|
+
explain <server> Explain the verdict for one server from the last scan
|
|
63
|
+
verify [target] Compare a fresh scan against the baseline (drift / rug-pull)
|
|
64
|
+
|
|
65
|
+
Advanced:
|
|
66
|
+
scan [target] Full ScanReport for an MCP config / npm:<pkg> / github:<repo>
|
|
61
67
|
diagnostics [target] Emit editor/agent-host diagnostics JSON (calllint.diagnostics.v0)
|
|
62
68
|
baseline [target] Record the approved risk surface as a baseline
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
approve Record the repo-wide capability surface as approved state (L4)
|
|
70
|
+
gen-rule --host <h> Emit the CallLint agent-safety rule for a host (CLAUDE.md, etc.)
|
|
65
71
|
policy init Write a default calllint.policy.json
|
|
66
72
|
policy explain Show the effective policy
|
|
67
73
|
help Show this help
|
|
68
74
|
|
|
75
|
+
CHECK OPTIONS
|
|
76
|
+
--stdin Read a config JSON or install snippet from stdin
|
|
77
|
+
--json Emit the compact decision JSON (calllint.decision.v0, <1 KB)
|
|
78
|
+
--explain Show the full evidence-backed report instead of the compact view
|
|
79
|
+
--no-emoji Plain-text symbols (good for CI logs)
|
|
80
|
+
|
|
69
81
|
TARGETS
|
|
70
82
|
<path> A config file (default: detect common locations)
|
|
71
83
|
npm:<pkg>[@ver] Synthesize a config for an npm package (offline)
|
|
@@ -79,6 +91,7 @@ SCAN OPTIONS
|
|
|
79
91
|
--sarif Emit SARIF 2.1.0 (GitHub Code Scanning / CI)
|
|
80
92
|
--markdown Emit Markdown for PR comments / GitHub Step Summary
|
|
81
93
|
--html Emit a self-contained HTML report
|
|
94
|
+
--badge Emit a shields.io endpoint badge JSON (SAFE/REVIEW/UNKNOWN/BLOCK)
|
|
82
95
|
--policy <file> Use a policy file (default: built-in defaults)
|
|
83
96
|
--stdin Read config JSON from stdin
|
|
84
97
|
--ci Exit non-zero per policy (BLOCK=30, UNKNOWN=20, REVIEW=10 if enabled)
|
|
@@ -86,19 +99,21 @@ SCAN OPTIONS
|
|
|
86
99
|
|
|
87
100
|
VERIFY OPTIONS
|
|
88
101
|
--baseline <file> Baseline path (default: .calllint/baseline.json)
|
|
89
|
-
--
|
|
102
|
+
--approved [file] Diff the repo-wide capability surface against approved state
|
|
103
|
+
(default: .calllint/approved.json) instead of the baseline
|
|
104
|
+
--ci Exit 40 if the surface drifted (from baseline or approved state)
|
|
90
105
|
--json Emit the drift report JSON
|
|
91
106
|
|
|
92
107
|
EXAMPLES
|
|
93
|
-
calllint
|
|
94
|
-
calllint
|
|
95
|
-
|
|
96
|
-
calllint scan
|
|
97
|
-
calllint
|
|
98
|
-
calllint scan
|
|
99
|
-
calllint scan
|
|
100
|
-
calllint baseline ./mcp.json
|
|
108
|
+
calllint check .cursor/mcp.json
|
|
109
|
+
calllint check npm:mcp-weather@1.0.0
|
|
110
|
+
echo "npx -y demo-mcp@1.2.3" | calllint check --stdin
|
|
111
|
+
calllint scan-all --no-emoji
|
|
112
|
+
calllint check ./mcp.json --json
|
|
113
|
+
calllint scan .cursor/mcp.json --markdown
|
|
114
|
+
calllint scan .cursor/mcp.json --badge > calllint-badge.json
|
|
101
115
|
calllint verify ./mcp.json --ci
|
|
116
|
+
calllint approve && calllint verify --approved --ci
|
|
102
117
|
calllint explain filesystem
|
|
103
118
|
`;
|
|
104
119
|
function helpCommand() {
|
|
@@ -106,7 +121,7 @@ function helpCommand() {
|
|
|
106
121
|
}
|
|
107
122
|
|
|
108
123
|
// src/commands/scan.ts
|
|
109
|
-
import { join as
|
|
124
|
+
import { join as join6, resolve as resolve2 } from "node:path";
|
|
110
125
|
|
|
111
126
|
// ../../packages/policy/src/defaultPolicy.ts
|
|
112
127
|
function defaultPolicy() {
|
|
@@ -364,6 +379,100 @@ function highestRiskClass(classes) {
|
|
|
364
379
|
return worst;
|
|
365
380
|
}
|
|
366
381
|
|
|
382
|
+
// ../../packages/types/src/reasonCodes.ts
|
|
383
|
+
var REASON_CODES = [
|
|
384
|
+
"UNPINNED_PACKAGE",
|
|
385
|
+
"UNKNOWN_REMOTE",
|
|
386
|
+
"SECRET_IN_WORKSPACE_CONFIG",
|
|
387
|
+
"BROAD_FILESYSTEM_ACCESS",
|
|
388
|
+
"SHELL_OR_DOCKER_EXECUTION",
|
|
389
|
+
"EXTERNAL_MUTATION_UNKNOWN",
|
|
390
|
+
"MONEY_OR_PAYMENT_CAPABILITY",
|
|
391
|
+
"MESSAGING_OR_EMAIL_SEND",
|
|
392
|
+
"PROMPT_METADATA_INSTRUCTION",
|
|
393
|
+
"OAUTH_SCOPE_UNKNOWN_OR_EXPANDED",
|
|
394
|
+
"TOOL_DESCRIPTOR_CHANGED",
|
|
395
|
+
"LONG_RUNNING_GATEWAY_RUNTIME"
|
|
396
|
+
];
|
|
397
|
+
var REASON_CODE_META = {
|
|
398
|
+
UNPINNED_PACKAGE: {
|
|
399
|
+
backedBy: ["supply.unpinned-package"],
|
|
400
|
+
status: "wired",
|
|
401
|
+
label: "Unpinned package"
|
|
402
|
+
},
|
|
403
|
+
UNKNOWN_REMOTE: {
|
|
404
|
+
backedBy: ["supply.unknown-remote"],
|
|
405
|
+
status: "wired",
|
|
406
|
+
label: "Unknown remote endpoint"
|
|
407
|
+
},
|
|
408
|
+
SECRET_IN_WORKSPACE_CONFIG: {
|
|
409
|
+
backedBy: ["secrets.env-key"],
|
|
410
|
+
status: "wired",
|
|
411
|
+
label: "Secret in workspace config"
|
|
412
|
+
},
|
|
413
|
+
BROAD_FILESYSTEM_ACCESS: {
|
|
414
|
+
backedBy: ["files.broad-path"],
|
|
415
|
+
status: "wired",
|
|
416
|
+
label: "Broad filesystem access"
|
|
417
|
+
},
|
|
418
|
+
SHELL_OR_DOCKER_EXECUTION: {
|
|
419
|
+
backedBy: ["exec.dangerous-command", "exec.unverified-local-source"],
|
|
420
|
+
status: "wired",
|
|
421
|
+
label: "Shell or Docker execution"
|
|
422
|
+
},
|
|
423
|
+
EXTERNAL_MUTATION_UNKNOWN: {
|
|
424
|
+
backedBy: ["action.external-mutation"],
|
|
425
|
+
status: "wired",
|
|
426
|
+
label: "Unknown external mutation"
|
|
427
|
+
},
|
|
428
|
+
MONEY_OR_PAYMENT_CAPABILITY: {
|
|
429
|
+
backedBy: ["action.financial", "action.financial-observed"],
|
|
430
|
+
status: "wired",
|
|
431
|
+
label: "Money or payment capability"
|
|
432
|
+
},
|
|
433
|
+
MESSAGING_OR_EMAIL_SEND: {
|
|
434
|
+
backedBy: ["action.messaging-send"],
|
|
435
|
+
status: "wired",
|
|
436
|
+
label: "Messaging or email send"
|
|
437
|
+
},
|
|
438
|
+
PROMPT_METADATA_INSTRUCTION: {
|
|
439
|
+
backedBy: ["prompt.hidden-instructions", "prompt.poisoning"],
|
|
440
|
+
status: "wired",
|
|
441
|
+
label: "Prompt-metadata instruction"
|
|
442
|
+
},
|
|
443
|
+
OAUTH_SCOPE_UNKNOWN_OR_EXPANDED: {
|
|
444
|
+
backedBy: ["auth.oauth-scope"],
|
|
445
|
+
status: "wired",
|
|
446
|
+
label: "OAuth scope unknown or expanded"
|
|
447
|
+
},
|
|
448
|
+
TOOL_DESCRIPTOR_CHANGED: {
|
|
449
|
+
// Backed by the drift signal (toolMetadataHash change), not a static detector.
|
|
450
|
+
// Wired via the verify/drift path in P1/P4, not findingsToReasonCodes.
|
|
451
|
+
backedBy: ["drift:toolMetadataHash"],
|
|
452
|
+
status: "wired",
|
|
453
|
+
label: "Tool descriptor changed"
|
|
454
|
+
},
|
|
455
|
+
LONG_RUNNING_GATEWAY_RUNTIME: {
|
|
456
|
+
backedBy: ["runtime.gateway"],
|
|
457
|
+
status: "wired",
|
|
458
|
+
label: "Long-running gateway runtime"
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
function reasonCodeForFinding(findingId) {
|
|
462
|
+
for (const code of REASON_CODES) {
|
|
463
|
+
if (REASON_CODE_META[code].backedBy.includes(findingId)) return code;
|
|
464
|
+
}
|
|
465
|
+
return void 0;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ../../packages/types/src/decision.ts
|
|
469
|
+
var VERDICT_NEXT_ACTION = {
|
|
470
|
+
SAFE: "continue",
|
|
471
|
+
REVIEW: "ask_before_continue",
|
|
472
|
+
BLOCK: "stop",
|
|
473
|
+
UNKNOWN: "gather_more_evidence"
|
|
474
|
+
};
|
|
475
|
+
|
|
367
476
|
// ../../packages/resolver/src/npmSpec.ts
|
|
368
477
|
var PACKAGE_RUNNERS = /* @__PURE__ */ new Set(["npx", "pnpm", "yarn", "bunx", "uvx", "pipx"]);
|
|
369
478
|
var SHELL_COMMANDS = /* @__PURE__ */ new Set([
|
|
@@ -660,6 +769,25 @@ function looksSecret(key) {
|
|
|
660
769
|
const upper = key.toUpperCase();
|
|
661
770
|
return SECRET_HINTS.some((h) => upper.includes(h));
|
|
662
771
|
}
|
|
772
|
+
function extractDockerEnvKeys(args) {
|
|
773
|
+
const keys = [];
|
|
774
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
775
|
+
const arg = args[i];
|
|
776
|
+
let spec;
|
|
777
|
+
if (arg === "-e" || arg === "--env") {
|
|
778
|
+
spec = args[i + 1];
|
|
779
|
+
i += 1;
|
|
780
|
+
} else if (arg.startsWith("-e=")) {
|
|
781
|
+
spec = arg.slice("-e=".length);
|
|
782
|
+
} else if (arg.startsWith("--env=")) {
|
|
783
|
+
spec = arg.slice("--env=".length);
|
|
784
|
+
}
|
|
785
|
+
if (spec === void 0) continue;
|
|
786
|
+
const eq = spec.indexOf("=");
|
|
787
|
+
keys.push(eq === -1 ? spec : spec.slice(0, eq));
|
|
788
|
+
}
|
|
789
|
+
return keys;
|
|
790
|
+
}
|
|
663
791
|
function detectSecretEnvKeys(ctx) {
|
|
664
792
|
const { server } = ctx;
|
|
665
793
|
const evidence = [];
|
|
@@ -674,6 +802,21 @@ function detectSecretEnvKeys(ctx) {
|
|
|
674
802
|
});
|
|
675
803
|
}
|
|
676
804
|
}
|
|
805
|
+
if ((server.command ?? "").toLowerCase() === "docker") {
|
|
806
|
+
const already = new Set(server.envKeys);
|
|
807
|
+
for (const key of extractDockerEnvKeys(server.args)) {
|
|
808
|
+
if (already.has(key)) continue;
|
|
809
|
+
already.add(key);
|
|
810
|
+
if (looksSecret(key)) {
|
|
811
|
+
evidence.push({
|
|
812
|
+
type: "config",
|
|
813
|
+
path: server.sourceConfigPath,
|
|
814
|
+
key: "args",
|
|
815
|
+
value: key
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
677
820
|
if (evidence.length === 0) return [];
|
|
678
821
|
return [
|
|
679
822
|
{
|
|
@@ -1217,6 +1360,177 @@ function detectHiddenInstructions(ctx) {
|
|
|
1217
1360
|
];
|
|
1218
1361
|
}
|
|
1219
1362
|
|
|
1363
|
+
// ../../packages/static-analyzer/src/detectors/messagingSend.ts
|
|
1364
|
+
var MESSAGING_PACKAGE_HINTS = [
|
|
1365
|
+
"slack",
|
|
1366
|
+
"discord",
|
|
1367
|
+
"telegram",
|
|
1368
|
+
"sendgrid",
|
|
1369
|
+
"twilio",
|
|
1370
|
+
"mailgun",
|
|
1371
|
+
"postmark",
|
|
1372
|
+
"gmail",
|
|
1373
|
+
"smtp",
|
|
1374
|
+
"nodemailer",
|
|
1375
|
+
"mailchimp",
|
|
1376
|
+
"ses-email",
|
|
1377
|
+
"whatsapp",
|
|
1378
|
+
"messagebird",
|
|
1379
|
+
"vonage",
|
|
1380
|
+
"nexmo"
|
|
1381
|
+
];
|
|
1382
|
+
var SEND_PATTERNS = [
|
|
1383
|
+
/\bsend[_-]?(e?mail|message|sms|text|dm|notification|push)\b/i,
|
|
1384
|
+
/\bpost[_-]?message\b/i,
|
|
1385
|
+
/\bsend[_-]?mail\b/i,
|
|
1386
|
+
/\b(e?mail|sms|message)[_-]?send\b/i,
|
|
1387
|
+
/\bdeliver[_-]?(message|email)\b/i
|
|
1388
|
+
];
|
|
1389
|
+
function packageHint(name) {
|
|
1390
|
+
if (!name) return void 0;
|
|
1391
|
+
const lower = name.toLowerCase();
|
|
1392
|
+
return MESSAGING_PACKAGE_HINTS.find((h) => lower.includes(h));
|
|
1393
|
+
}
|
|
1394
|
+
function textMatchesSend(text) {
|
|
1395
|
+
if (!text) return false;
|
|
1396
|
+
return SEND_PATTERNS.some((re) => re.test(text));
|
|
1397
|
+
}
|
|
1398
|
+
function detectMessagingSend(ctx) {
|
|
1399
|
+
const { server, binding } = ctx;
|
|
1400
|
+
const evidence = [];
|
|
1401
|
+
let observed = false;
|
|
1402
|
+
for (const tool of server.providedTools) {
|
|
1403
|
+
if (textMatchesSend(tool.name) || textMatchesSend(tool.description)) {
|
|
1404
|
+
observed = true;
|
|
1405
|
+
evidence.push({ type: "tool-metadata", key: "tool", value: tool.name ?? "(unnamed)" });
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
const pkgHint = packageHint(binding.packageName);
|
|
1409
|
+
if (pkgHint) {
|
|
1410
|
+
evidence.push({ type: "runtime-binding", key: "package", value: binding.packageName });
|
|
1411
|
+
}
|
|
1412
|
+
if (evidence.length === 0) return [];
|
|
1413
|
+
return [
|
|
1414
|
+
{
|
|
1415
|
+
id: "action.messaging-send",
|
|
1416
|
+
title: "May send messages or email on your behalf",
|
|
1417
|
+
severity: "medium",
|
|
1418
|
+
blocker: false,
|
|
1419
|
+
symbol: "ACTION",
|
|
1420
|
+
riskClass: "S2",
|
|
1421
|
+
mode: observed ? "OBSERVED" : "INFERRED",
|
|
1422
|
+
confidence: observed ? "high" : "low",
|
|
1423
|
+
detectionMethod: observed ? "tool-metadata" : "package-metadata",
|
|
1424
|
+
evidence,
|
|
1425
|
+
impact: "The server appears able to send messages (email, SMS, chat, or push) as you. A compromised or confused agent could send unwanted or harmful messages.",
|
|
1426
|
+
fix: "Confirm which send tools are exposed, require approval before sending, and scope credentials to the minimum needed.",
|
|
1427
|
+
falsePositiveNote: observed ? void 0 : "Name-based inference; a read-only integration with the same provider would not actually send."
|
|
1428
|
+
}
|
|
1429
|
+
];
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// ../../packages/static-analyzer/src/detectors/oauthScope.ts
|
|
1433
|
+
var BROAD_SCOPE_HINTS = [
|
|
1434
|
+
"*",
|
|
1435
|
+
"admin",
|
|
1436
|
+
"full_access",
|
|
1437
|
+
"fullaccess",
|
|
1438
|
+
"read_write_all",
|
|
1439
|
+
"offline_access",
|
|
1440
|
+
"all",
|
|
1441
|
+
"write:org",
|
|
1442
|
+
"repo"
|
|
1443
|
+
];
|
|
1444
|
+
function readOauth(raw) {
|
|
1445
|
+
if (typeof raw !== "object" || raw === null) return void 0;
|
|
1446
|
+
const oauth = raw.oauth;
|
|
1447
|
+
if (typeof oauth !== "object" || oauth === null) return void 0;
|
|
1448
|
+
const scopesRaw = oauth.scopes;
|
|
1449
|
+
const scopes = Array.isArray(scopesRaw) ? scopesRaw.filter((s) => typeof s === "string") : void 0;
|
|
1450
|
+
return { scopes };
|
|
1451
|
+
}
|
|
1452
|
+
function broadScope(scopes) {
|
|
1453
|
+
for (const s of scopes) {
|
|
1454
|
+
const lower = s.toLowerCase();
|
|
1455
|
+
if (BROAD_SCOPE_HINTS.some((h) => lower === h || lower.includes(h))) return s;
|
|
1456
|
+
}
|
|
1457
|
+
return void 0;
|
|
1458
|
+
}
|
|
1459
|
+
function detectOauthScope(ctx) {
|
|
1460
|
+
const oauth = readOauth(ctx.server.raw);
|
|
1461
|
+
if (!oauth) return [];
|
|
1462
|
+
const evidence = [{ type: "config", key: "oauth", value: "present" }];
|
|
1463
|
+
if (!oauth.scopes || oauth.scopes.length === 0) {
|
|
1464
|
+
return [
|
|
1465
|
+
{
|
|
1466
|
+
id: "auth.oauth-scope",
|
|
1467
|
+
title: "OAuth scope is not declared",
|
|
1468
|
+
severity: "medium",
|
|
1469
|
+
blocker: false,
|
|
1470
|
+
symbol: "ACTION",
|
|
1471
|
+
riskClass: "S2",
|
|
1472
|
+
mode: "INFERRED",
|
|
1473
|
+
confidence: "low",
|
|
1474
|
+
detectionMethod: "config-analysis",
|
|
1475
|
+
evidence,
|
|
1476
|
+
impact: "This server authenticates via OAuth but does not declare its scopes, so the powers it grants the agent are unknown.",
|
|
1477
|
+
fix: "Declare the exact OAuth scopes the server requests and confirm they are the minimum required.",
|
|
1478
|
+
falsePositiveNote: "Scope may be declared elsewhere; CallLint only sees the provided config."
|
|
1479
|
+
}
|
|
1480
|
+
];
|
|
1481
|
+
}
|
|
1482
|
+
const broad = broadScope(oauth.scopes);
|
|
1483
|
+
if (broad) {
|
|
1484
|
+
evidence.push({ type: "config", key: "scope", value: broad });
|
|
1485
|
+
return [
|
|
1486
|
+
{
|
|
1487
|
+
id: "auth.oauth-scope",
|
|
1488
|
+
title: "OAuth scope is broad or expansive",
|
|
1489
|
+
severity: "high",
|
|
1490
|
+
blocker: false,
|
|
1491
|
+
symbol: "ACTION",
|
|
1492
|
+
riskClass: "S2",
|
|
1493
|
+
mode: "OBSERVED",
|
|
1494
|
+
confidence: "high",
|
|
1495
|
+
detectionMethod: "config-analysis",
|
|
1496
|
+
evidence,
|
|
1497
|
+
impact: `The server requests a broad OAuth scope (\`${broad}\`), granting the agent wide access to the connected account.`,
|
|
1498
|
+
fix: "Narrow the OAuth scopes to only what the exposed tools need."
|
|
1499
|
+
}
|
|
1500
|
+
];
|
|
1501
|
+
}
|
|
1502
|
+
return [];
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// ../../packages/static-analyzer/src/detectors/gatewayRuntime.ts
|
|
1506
|
+
function gatewayName(raw) {
|
|
1507
|
+
if (typeof raw !== "object" || raw === null) return void 0;
|
|
1508
|
+
const g = raw.gateway;
|
|
1509
|
+
return typeof g === "string" ? g : void 0;
|
|
1510
|
+
}
|
|
1511
|
+
function detectGatewayRuntime(ctx) {
|
|
1512
|
+
const gateway = gatewayName(ctx.server.raw);
|
|
1513
|
+
if (!gateway) return [];
|
|
1514
|
+
const evidence = [{ type: "config", key: "gateway", value: gateway }];
|
|
1515
|
+
return [
|
|
1516
|
+
{
|
|
1517
|
+
id: "runtime.gateway",
|
|
1518
|
+
title: "Long-running gateway runtime",
|
|
1519
|
+
severity: "medium",
|
|
1520
|
+
blocker: false,
|
|
1521
|
+
symbol: "ACTION",
|
|
1522
|
+
riskClass: "S2",
|
|
1523
|
+
mode: "OBSERVED",
|
|
1524
|
+
confidence: "high",
|
|
1525
|
+
detectionMethod: "config-analysis",
|
|
1526
|
+
evidence,
|
|
1527
|
+
impact: "This is a gateway runtime that proxies and re-exposes many downstream tools under its own process and auth. Its tool surface can change after you approve it.",
|
|
1528
|
+
fix: "Treat the gateway as a standing capability: pin its version, review which downstream tools it exposes, and re-verify after upgrades.",
|
|
1529
|
+
falsePositiveNote: "A gateway you fully control and monitor may be acceptable; the verdict reflects standing capability, not a specific exploit."
|
|
1530
|
+
}
|
|
1531
|
+
];
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1220
1534
|
// ../../packages/static-analyzer/src/analyzeServerConfig.ts
|
|
1221
1535
|
var DETECTORS = [
|
|
1222
1536
|
detectBroadFilesystemPath,
|
|
@@ -1228,7 +1542,10 @@ var DETECTORS = [
|
|
|
1228
1542
|
detectExternalMutation,
|
|
1229
1543
|
detectFinancialAction,
|
|
1230
1544
|
detectUnverifiedLocalSource,
|
|
1231
|
-
detectHiddenInstructions
|
|
1545
|
+
detectHiddenInstructions,
|
|
1546
|
+
detectMessagingSend,
|
|
1547
|
+
detectOauthScope,
|
|
1548
|
+
detectGatewayRuntime
|
|
1232
1549
|
];
|
|
1233
1550
|
function analyzeServerConfig(server) {
|
|
1234
1551
|
const binding = resolveRuntimeBinding(server);
|
|
@@ -2050,6 +2367,617 @@ function readBaseline(path = defaultBaselinePath()) {
|
|
|
2050
2367
|
}
|
|
2051
2368
|
}
|
|
2052
2369
|
|
|
2370
|
+
// ../../packages/core/src/extract/fingerprint.ts
|
|
2371
|
+
function deriveLaunch(binding) {
|
|
2372
|
+
switch (binding.runtimeKind) {
|
|
2373
|
+
case "npx":
|
|
2374
|
+
return "local:npx";
|
|
2375
|
+
case "uvx":
|
|
2376
|
+
return "local:uvx";
|
|
2377
|
+
case "node":
|
|
2378
|
+
return "local:node";
|
|
2379
|
+
case "python":
|
|
2380
|
+
return "local:python";
|
|
2381
|
+
case "docker":
|
|
2382
|
+
return "local:docker";
|
|
2383
|
+
case "http":
|
|
2384
|
+
return "remote:http";
|
|
2385
|
+
case "sse":
|
|
2386
|
+
return "remote:sse";
|
|
2387
|
+
default:
|
|
2388
|
+
return "unknown";
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
function deriveTransport(binding) {
|
|
2392
|
+
switch (binding.transport) {
|
|
2393
|
+
case "stdio":
|
|
2394
|
+
return "stdio";
|
|
2395
|
+
case "http":
|
|
2396
|
+
return "http";
|
|
2397
|
+
case "sse":
|
|
2398
|
+
return "sse";
|
|
2399
|
+
default:
|
|
2400
|
+
return "unknown";
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
function deriveSource(binding) {
|
|
2404
|
+
if (binding.packageName) {
|
|
2405
|
+
const spec = binding.packageVersionSpec ? `${binding.packageName}@${binding.packageVersionSpec}` : binding.packageName;
|
|
2406
|
+
return `npm:${spec}`;
|
|
2407
|
+
}
|
|
2408
|
+
if (binding.remoteUrl) {
|
|
2409
|
+
try {
|
|
2410
|
+
const u = new URL(binding.remoteUrl);
|
|
2411
|
+
return `url:${u.host}${u.pathname}`.replace(/\/$/, "");
|
|
2412
|
+
} catch {
|
|
2413
|
+
return `url:${binding.remoteUrl}`;
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
if (binding.declaredCommand) {
|
|
2417
|
+
return `local:${binding.declaredCommand}`;
|
|
2418
|
+
}
|
|
2419
|
+
return "unknown";
|
|
2420
|
+
}
|
|
2421
|
+
function deriveScope(origin, launch) {
|
|
2422
|
+
if (origin === "workspace") return "workspace";
|
|
2423
|
+
if (origin === "user") return "user";
|
|
2424
|
+
if (origin === "system") return "system";
|
|
2425
|
+
if (origin === "remote") return "external";
|
|
2426
|
+
if (launch === "remote:http" || launch === "remote:sse") return "external";
|
|
2427
|
+
return "unknown";
|
|
2428
|
+
}
|
|
2429
|
+
function deriveEffects(findings, server, kind) {
|
|
2430
|
+
const effects = /* @__PURE__ */ new Set();
|
|
2431
|
+
for (const f of findings) {
|
|
2432
|
+
switch (f.symbol) {
|
|
2433
|
+
case "FILES":
|
|
2434
|
+
effects.add("filesystem_broad");
|
|
2435
|
+
break;
|
|
2436
|
+
case "EXEC":
|
|
2437
|
+
effects.add("local_execution");
|
|
2438
|
+
break;
|
|
2439
|
+
case "NETWORK":
|
|
2440
|
+
effects.add("network_egress");
|
|
2441
|
+
break;
|
|
2442
|
+
case "ACTION":
|
|
2443
|
+
effects.add("external_mutation_unknown");
|
|
2444
|
+
break;
|
|
2445
|
+
case "MONEY":
|
|
2446
|
+
effects.add("payment");
|
|
2447
|
+
break;
|
|
2448
|
+
case "PROMPT":
|
|
2449
|
+
effects.add("prompt_instruction");
|
|
2450
|
+
break;
|
|
2451
|
+
default:
|
|
2452
|
+
break;
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
if (kind === "gateway_runtime") effects.add("gateway_runtime");
|
|
2456
|
+
if (hasOauthMetadata(server)) effects.add("oauth_scope");
|
|
2457
|
+
return [...effects].sort();
|
|
2458
|
+
}
|
|
2459
|
+
function hasOauthMetadata(server) {
|
|
2460
|
+
const raw = server.raw;
|
|
2461
|
+
return typeof raw === "object" && raw !== null && "oauth" in raw;
|
|
2462
|
+
}
|
|
2463
|
+
function deriveIdentity(binding) {
|
|
2464
|
+
return binding.sourceKnown ? "known" : "unknown";
|
|
2465
|
+
}
|
|
2466
|
+
function buildFingerprint(input) {
|
|
2467
|
+
const { server, binding, findings, origin, kind } = input;
|
|
2468
|
+
const launch = deriveLaunch(binding);
|
|
2469
|
+
const resolvedKind = kind ?? "mcp_server";
|
|
2470
|
+
return {
|
|
2471
|
+
schemaVersion: "calllint.fingerprint.v0",
|
|
2472
|
+
kind: resolvedKind,
|
|
2473
|
+
source: deriveSource(binding),
|
|
2474
|
+
launch,
|
|
2475
|
+
transport: deriveTransport(binding),
|
|
2476
|
+
// Env KEY NAMES only — never values (secret redaction by construction).
|
|
2477
|
+
authority: [...new Set(server.envKeys)].sort().map((k) => `env:${k}`),
|
|
2478
|
+
scope: deriveScope(origin, launch),
|
|
2479
|
+
effects: deriveEffects(findings, server, resolvedKind),
|
|
2480
|
+
identity: deriveIdentity(binding)
|
|
2481
|
+
};
|
|
2482
|
+
}
|
|
2483
|
+
function fingerprintHash(fp) {
|
|
2484
|
+
const { schemaVersion: _schemaVersion, ...semantic } = fp;
|
|
2485
|
+
const normalized = {
|
|
2486
|
+
...semantic,
|
|
2487
|
+
authority: [...new Set(fp.authority)].sort(),
|
|
2488
|
+
effects: [...new Set(fp.effects)].sort()
|
|
2489
|
+
};
|
|
2490
|
+
return hashJson(normalized);
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
// ../../packages/core/src/rules/reasonCodes.ts
|
|
2494
|
+
function findingsToReasonCodes(findings) {
|
|
2495
|
+
const present = /* @__PURE__ */ new Set();
|
|
2496
|
+
for (const f of findings) {
|
|
2497
|
+
const code = reasonCodeForFinding(f.id);
|
|
2498
|
+
if (code) present.add(code);
|
|
2499
|
+
}
|
|
2500
|
+
return REASON_CODES.filter((c) => present.has(c));
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
// ../../packages/core/src/rules/sparseRules.ts
|
|
2504
|
+
function sparseDecision(verdict, findings) {
|
|
2505
|
+
return {
|
|
2506
|
+
verdict,
|
|
2507
|
+
reasonCodes: findingsToReasonCodes(findings),
|
|
2508
|
+
nextAction: VERDICT_NEXT_ACTION[verdict]
|
|
2509
|
+
};
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
// ../../packages/core/src/decision/decide.ts
|
|
2513
|
+
function toCompactDecision(report, surface, fingerprint) {
|
|
2514
|
+
const fp = fingerprint ?? report.fingerprint;
|
|
2515
|
+
const hash = fp ? fingerprintHash(fp) : "";
|
|
2516
|
+
const sparse = sparseDecision(report.verdict, report.findings);
|
|
2517
|
+
return {
|
|
2518
|
+
schemaVersion: "calllint.decision.v0",
|
|
2519
|
+
verdict: sparse.verdict,
|
|
2520
|
+
surface,
|
|
2521
|
+
fingerprintHash: hash,
|
|
2522
|
+
reasonCodes: sparse.reasonCodes,
|
|
2523
|
+
nextAction: sparse.nextAction
|
|
2524
|
+
};
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
// ../../packages/core/src/decision/checkParsed.ts
|
|
2528
|
+
function checkParsed(parsed, surface, origin, opts) {
|
|
2529
|
+
resolveScanOptions(opts);
|
|
2530
|
+
return parsed.servers.map((server) => {
|
|
2531
|
+
const binding = resolveRuntimeBinding(server);
|
|
2532
|
+
const findings = analyzeServerConfig(server);
|
|
2533
|
+
const report = scanServer({ server, targetKind: parsed.kind }, opts);
|
|
2534
|
+
const fingerprint = buildFingerprint({ server, binding, findings, origin });
|
|
2535
|
+
const decision = toCompactDecision(report, surface, fingerprint);
|
|
2536
|
+
return { decision: { ...decision }, report: { ...report, fingerprint, decision } };
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
// ../../packages/core/src/surface/detect.ts
|
|
2541
|
+
var MCP_CONFIG_BASENAMES = /* @__PURE__ */ new Set([
|
|
2542
|
+
".mcp.json",
|
|
2543
|
+
"mcp.json",
|
|
2544
|
+
"claude_desktop_config.json",
|
|
2545
|
+
"mcp_config.json"
|
|
2546
|
+
]);
|
|
2547
|
+
var MCP_PATH_HINTS = [
|
|
2548
|
+
"/.cursor/mcp.json",
|
|
2549
|
+
"/.vscode/mcp.json",
|
|
2550
|
+
"\\.cursor\\mcp.json",
|
|
2551
|
+
"\\.vscode\\mcp.json"
|
|
2552
|
+
];
|
|
2553
|
+
var SNIPPET_MARKERS = [
|
|
2554
|
+
/\bclaude\s+mcp\s+add\b/,
|
|
2555
|
+
/\bnpx\s+-y?\b/,
|
|
2556
|
+
/\buvx\b/,
|
|
2557
|
+
/\bbunx\b/,
|
|
2558
|
+
/\bdocker\s+run\b/,
|
|
2559
|
+
/\bopenclaw\s+mcp\b/
|
|
2560
|
+
];
|
|
2561
|
+
function normalize(path) {
|
|
2562
|
+
return path.replace(/\\/g, "/").toLowerCase();
|
|
2563
|
+
}
|
|
2564
|
+
function basename2(path) {
|
|
2565
|
+
const n = normalize(path);
|
|
2566
|
+
const i = n.lastIndexOf("/");
|
|
2567
|
+
return i === -1 ? n : n.slice(i + 1);
|
|
2568
|
+
}
|
|
2569
|
+
function classifySurface(path, content) {
|
|
2570
|
+
const n = normalize(path);
|
|
2571
|
+
if (n.includes("/node_modules/") || n.startsWith("node_modules/")) return "NOOP";
|
|
2572
|
+
if (/(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock)$/.test(n)) return "NOOP";
|
|
2573
|
+
const base = basename2(path);
|
|
2574
|
+
if (MCP_CONFIG_BASENAMES.has(base)) return "SCAN";
|
|
2575
|
+
if (MCP_PATH_HINTS.some((h) => normalize(path).includes(normalize(h)))) return "SCAN";
|
|
2576
|
+
if (base === "config.toml" && content && /\[mcp_servers?\.[^\]]+\]/.test(content)) {
|
|
2577
|
+
return "SCAN";
|
|
2578
|
+
}
|
|
2579
|
+
if (base === "settings.json" && content && /"mcpServers"\s*:/.test(content)) {
|
|
2580
|
+
return "SCAN";
|
|
2581
|
+
}
|
|
2582
|
+
if (/\.(md|markdown)$/.test(n) && content && SNIPPET_MARKERS.some((r) => r.test(content))) {
|
|
2583
|
+
return "SCAN";
|
|
2584
|
+
}
|
|
2585
|
+
if (/\/\.github\/workflows\/[^/]+\.ya?ml$/.test(n) && content && SNIPPET_MARKERS.some((r) => r.test(content))) {
|
|
2586
|
+
return "SCAN";
|
|
2587
|
+
}
|
|
2588
|
+
if ((path === "-" || path === "" || base === "stdin") && content && SNIPPET_MARKERS.some((r) => r.test(content))) {
|
|
2589
|
+
return "SCAN";
|
|
2590
|
+
}
|
|
2591
|
+
return "NOOP";
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
// ../../packages/core/src/surface/load.ts
|
|
2595
|
+
function inferOrigin(path) {
|
|
2596
|
+
const n = path.replace(/\\/g, "/").toLowerCase();
|
|
2597
|
+
if (n.startsWith("npm:") || n.startsWith("http://") || n.startsWith("https://")) {
|
|
2598
|
+
return "remote";
|
|
2599
|
+
}
|
|
2600
|
+
if (n.includes("/library/application support/") || // macOS
|
|
2601
|
+
n.includes("/.config/") || n.includes("/appdata/") || // Windows
|
|
2602
|
+
n.includes("/users/") && n.includes("/.cursor/") === false && n.includes("/.vscode/") === false) {
|
|
2603
|
+
if (!n.includes("/.cursor/") && !n.includes("/.vscode/") && !n.startsWith("./")) {
|
|
2604
|
+
return "user";
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
if (n.includes("/.cursor/") || n.includes("/.vscode/") || n.includes("/.github/") || n.startsWith(".cursor/") || n.startsWith(".vscode/") || n.startsWith(".github/") || n === ".mcp.json" || n.startsWith("./")) {
|
|
2608
|
+
return "workspace";
|
|
2609
|
+
}
|
|
2610
|
+
return "unknown";
|
|
2611
|
+
}
|
|
2612
|
+
function loadSurfaceText(text, configPath = "<inline>") {
|
|
2613
|
+
return { parsed: parseConfigText(text, configPath), origin: inferOrigin(configPath) };
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
// ../../packages/core/src/surface/snippet.ts
|
|
2617
|
+
var RUNNER_RE = /\b(?:npx|uvx|bunx|pnpm dlx)\s+(?:-y\s+|--yes\s+)?(@?[\w./-]+(?:@[\w.^~>=<-]+)?)/;
|
|
2618
|
+
var CLAUDE_ADD_RE = /\bclaude\s+mcp\s+add\b.*?--\s+(.+)$/;
|
|
2619
|
+
function extractPackageSpec(text) {
|
|
2620
|
+
const trimmed = text.trim();
|
|
2621
|
+
const claude = CLAUDE_ADD_RE.exec(trimmed);
|
|
2622
|
+
if (claude) {
|
|
2623
|
+
const inner = extractPackageSpec(claude[1]);
|
|
2624
|
+
if (inner) return inner;
|
|
2625
|
+
}
|
|
2626
|
+
const runner = RUNNER_RE.exec(trimmed);
|
|
2627
|
+
if (runner) return runner[1];
|
|
2628
|
+
if (/^@?[\w./-]+(@[\w.^~>=<-]+)?$/.test(trimmed)) return trimmed;
|
|
2629
|
+
return void 0;
|
|
2630
|
+
}
|
|
2631
|
+
function parseSnippet(text) {
|
|
2632
|
+
const spec = extractPackageSpec(text);
|
|
2633
|
+
if (!spec) {
|
|
2634
|
+
throw new Error("No agent-tool package recognized in snippet");
|
|
2635
|
+
}
|
|
2636
|
+
const { text: configText, configPath } = synthesizeNpmConfig(spec);
|
|
2637
|
+
return { parsed: parseConfigText(configText, configPath), packageSpec: spec };
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
// ../../packages/core/src/surface/walk.ts
|
|
2641
|
+
import { readdirSync, readFileSync as readFileSync3, statSync } from "node:fs";
|
|
2642
|
+
import { join as join2, relative } from "node:path";
|
|
2643
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
2644
|
+
"node_modules",
|
|
2645
|
+
".git",
|
|
2646
|
+
"dist",
|
|
2647
|
+
"build",
|
|
2648
|
+
".next",
|
|
2649
|
+
"coverage"
|
|
2650
|
+
]);
|
|
2651
|
+
var MAX_DEPTH = 6;
|
|
2652
|
+
var MAX_FILE_BYTES = 256 * 1024;
|
|
2653
|
+
function findSurfaces(root) {
|
|
2654
|
+
const found = [];
|
|
2655
|
+
function walk(dir, depth) {
|
|
2656
|
+
if (depth > MAX_DEPTH) return;
|
|
2657
|
+
let entries;
|
|
2658
|
+
try {
|
|
2659
|
+
entries = readdirSync(dir, { withFileTypes: true, encoding: "utf8" });
|
|
2660
|
+
} catch {
|
|
2661
|
+
return;
|
|
2662
|
+
}
|
|
2663
|
+
for (const e of entries) {
|
|
2664
|
+
const full = join2(dir, e.name);
|
|
2665
|
+
if (e.isDirectory()) {
|
|
2666
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
2667
|
+
walk(full, depth + 1);
|
|
2668
|
+
} else if (e.isFile()) {
|
|
2669
|
+
if (classifySurface(full) === "SCAN") {
|
|
2670
|
+
found.push(full);
|
|
2671
|
+
continue;
|
|
2672
|
+
}
|
|
2673
|
+
if (needsContentCheck(e.name)) {
|
|
2674
|
+
const content = readCapped(full);
|
|
2675
|
+
if (content && classifySurface(full, content) === "SCAN") found.push(full);
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
walk(root, 0);
|
|
2681
|
+
return found;
|
|
2682
|
+
}
|
|
2683
|
+
function needsContentCheck(name) {
|
|
2684
|
+
return name === "config.toml" || name === "settings.json" || /\.(md|markdown|ya?ml)$/.test(name);
|
|
2685
|
+
}
|
|
2686
|
+
function readCapped(path) {
|
|
2687
|
+
try {
|
|
2688
|
+
const st = statSync(path);
|
|
2689
|
+
if (!st.isFile() || st.size > MAX_FILE_BYTES) return void 0;
|
|
2690
|
+
return readFileSync3(path, "utf8");
|
|
2691
|
+
} catch {
|
|
2692
|
+
return void 0;
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
function unknownDecision(surface) {
|
|
2696
|
+
return {
|
|
2697
|
+
schemaVersion: "calllint.decision.v0",
|
|
2698
|
+
verdict: "UNKNOWN",
|
|
2699
|
+
surface,
|
|
2700
|
+
fingerprintHash: "",
|
|
2701
|
+
reasonCodes: [],
|
|
2702
|
+
nextAction: "gather_more_evidence"
|
|
2703
|
+
};
|
|
2704
|
+
}
|
|
2705
|
+
function decideRepoSurfaces(root, opts) {
|
|
2706
|
+
const surfaces = findSurfaces(root);
|
|
2707
|
+
const decisions = [];
|
|
2708
|
+
for (const abs of surfaces) {
|
|
2709
|
+
const rel = relative(root, abs) || abs;
|
|
2710
|
+
const text = readCapped(abs);
|
|
2711
|
+
if (!text) continue;
|
|
2712
|
+
try {
|
|
2713
|
+
const loaded = loadSurfaceText(text, rel);
|
|
2714
|
+
const results = checkParsed(loaded.parsed, rel, inferOrigin(rel), opts);
|
|
2715
|
+
for (const r of results) decisions.push(r.decision);
|
|
2716
|
+
} catch (e) {
|
|
2717
|
+
if (e instanceof ConfigParseError) {
|
|
2718
|
+
decisions.push(unknownDecision(rel));
|
|
2719
|
+
continue;
|
|
2720
|
+
}
|
|
2721
|
+
throw e;
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
return decisions;
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
// ../../packages/core/src/state/approve.ts
|
|
2728
|
+
import { writeFileSync as writeFileSync2, readFileSync as readFileSync4, mkdirSync as mkdirSync2, existsSync as existsSync2 } from "node:fs";
|
|
2729
|
+
import { dirname as dirname2, join as join3 } from "node:path";
|
|
2730
|
+
function defaultApprovedPath(cwd = process.cwd()) {
|
|
2731
|
+
return join3(cwd, ".calllint", "approved.json");
|
|
2732
|
+
}
|
|
2733
|
+
function entryFromDecision(d, approvedAt) {
|
|
2734
|
+
const entry = {
|
|
2735
|
+
fingerprintHash: d.fingerprintHash,
|
|
2736
|
+
surface: d.surface,
|
|
2737
|
+
verdict: d.verdict,
|
|
2738
|
+
approvedAt
|
|
2739
|
+
};
|
|
2740
|
+
if (d.reasonCodes.length > 0) entry.reasonCodes = [...d.reasonCodes];
|
|
2741
|
+
return entry;
|
|
2742
|
+
}
|
|
2743
|
+
function buildApproved(decisions, approvedAt) {
|
|
2744
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2745
|
+
const approved = [];
|
|
2746
|
+
for (const d of decisions) {
|
|
2747
|
+
const key = `${d.surface}\0${d.fingerprintHash}`;
|
|
2748
|
+
if (seen.has(key)) continue;
|
|
2749
|
+
seen.add(key);
|
|
2750
|
+
approved.push(entryFromDecision(d, approvedAt));
|
|
2751
|
+
}
|
|
2752
|
+
approved.sort(
|
|
2753
|
+
(a, b) => a.surface === b.surface ? a.fingerprintHash.localeCompare(b.fingerprintHash) : a.surface.localeCompare(b.surface)
|
|
2754
|
+
);
|
|
2755
|
+
return { schemaVersion: "calllint.approved.v0", approved };
|
|
2756
|
+
}
|
|
2757
|
+
function writeApproved(state, path = defaultApprovedPath()) {
|
|
2758
|
+
mkdirSync2(dirname2(path), { recursive: true });
|
|
2759
|
+
writeFileSync2(path, JSON.stringify(state, null, 2), "utf8");
|
|
2760
|
+
}
|
|
2761
|
+
function readApproved(path = defaultApprovedPath()) {
|
|
2762
|
+
if (!existsSync2(path)) return void 0;
|
|
2763
|
+
try {
|
|
2764
|
+
return JSON.parse(readFileSync4(path, "utf8"));
|
|
2765
|
+
} catch {
|
|
2766
|
+
return void 0;
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
// ../../packages/core/src/state/verifyApproved.ts
|
|
2771
|
+
function verifyApproved(current, approved, generatedAt) {
|
|
2772
|
+
const approvedBySurface = /* @__PURE__ */ new Map();
|
|
2773
|
+
for (const a of approved.approved) approvedBySurface.set(a.surface, a);
|
|
2774
|
+
const currentBySurface = /* @__PURE__ */ new Map();
|
|
2775
|
+
for (const d of current) currentBySurface.set(d.surface, d);
|
|
2776
|
+
const entries = [];
|
|
2777
|
+
const driftVerdicts = [];
|
|
2778
|
+
for (const a of approved.approved) {
|
|
2779
|
+
const cur = currentBySurface.get(a.surface);
|
|
2780
|
+
if (!cur) {
|
|
2781
|
+
entries.push({
|
|
2782
|
+
surface: a.surface,
|
|
2783
|
+
status: "removed",
|
|
2784
|
+
approvedHash: a.fingerprintHash,
|
|
2785
|
+
approvedVerdict: a.verdict
|
|
2786
|
+
});
|
|
2787
|
+
driftVerdicts.push("REVIEW");
|
|
2788
|
+
continue;
|
|
2789
|
+
}
|
|
2790
|
+
if (cur.fingerprintHash !== a.fingerprintHash) {
|
|
2791
|
+
entries.push({
|
|
2792
|
+
surface: a.surface,
|
|
2793
|
+
status: "hash-changed",
|
|
2794
|
+
approvedHash: a.fingerprintHash,
|
|
2795
|
+
currentHash: cur.fingerprintHash,
|
|
2796
|
+
approvedVerdict: a.verdict,
|
|
2797
|
+
currentVerdict: cur.verdict
|
|
2798
|
+
});
|
|
2799
|
+
driftVerdicts.push(escalate(cur.verdict));
|
|
2800
|
+
continue;
|
|
2801
|
+
}
|
|
2802
|
+
if (cur.verdict !== a.verdict) {
|
|
2803
|
+
entries.push({
|
|
2804
|
+
surface: a.surface,
|
|
2805
|
+
status: "verdict-changed",
|
|
2806
|
+
approvedHash: a.fingerprintHash,
|
|
2807
|
+
currentHash: cur.fingerprintHash,
|
|
2808
|
+
approvedVerdict: a.verdict,
|
|
2809
|
+
currentVerdict: cur.verdict
|
|
2810
|
+
});
|
|
2811
|
+
driftVerdicts.push(escalate(cur.verdict));
|
|
2812
|
+
continue;
|
|
2813
|
+
}
|
|
2814
|
+
entries.push({
|
|
2815
|
+
surface: a.surface,
|
|
2816
|
+
status: "unchanged",
|
|
2817
|
+
approvedHash: a.fingerprintHash,
|
|
2818
|
+
currentHash: cur.fingerprintHash,
|
|
2819
|
+
approvedVerdict: a.verdict,
|
|
2820
|
+
currentVerdict: cur.verdict
|
|
2821
|
+
});
|
|
2822
|
+
}
|
|
2823
|
+
for (const d of current) {
|
|
2824
|
+
if (!approvedBySurface.has(d.surface)) {
|
|
2825
|
+
entries.push({
|
|
2826
|
+
surface: d.surface,
|
|
2827
|
+
status: "added",
|
|
2828
|
+
currentHash: d.fingerprintHash,
|
|
2829
|
+
currentVerdict: d.verdict
|
|
2830
|
+
});
|
|
2831
|
+
driftVerdicts.push(escalate(d.verdict));
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
const drifted = entries.some((e) => e.status !== "unchanged");
|
|
2835
|
+
const verdict = drifted ? mostSevereVerdict(driftVerdicts) : "SAFE";
|
|
2836
|
+
return {
|
|
2837
|
+
schemaVersion: "calllint.approveddrift.v0",
|
|
2838
|
+
drifted,
|
|
2839
|
+
verdict,
|
|
2840
|
+
entries,
|
|
2841
|
+
generatedAt
|
|
2842
|
+
};
|
|
2843
|
+
}
|
|
2844
|
+
function escalate(current) {
|
|
2845
|
+
return current === "BLOCK" || current === "UNKNOWN" ? current : "REVIEW";
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
// ../../packages/core/src/distribution/agentRule.ts
|
|
2849
|
+
var RELEVANT_SURFACES = [
|
|
2850
|
+
".cursor/mcp.json",
|
|
2851
|
+
".vscode/mcp.json",
|
|
2852
|
+
".mcp.json",
|
|
2853
|
+
".claude/settings.json",
|
|
2854
|
+
"claude_desktop_config.json",
|
|
2855
|
+
"~/.claude.json",
|
|
2856
|
+
".codex/config.toml",
|
|
2857
|
+
".gemini/settings.json",
|
|
2858
|
+
"package.json",
|
|
2859
|
+
"README.md",
|
|
2860
|
+
"AGENTS.md",
|
|
2861
|
+
"CLAUDE.md",
|
|
2862
|
+
".cursor/rules/*.mdc",
|
|
2863
|
+
".github/workflows/*.yml"
|
|
2864
|
+
];
|
|
2865
|
+
var UNIVERSAL_AGENT_RULE = `Before adding, editing, approving, installing, running, publishing, or merging any MCP or agent-tool configuration, run CallLint first and explain the result.
|
|
2866
|
+
|
|
2867
|
+
Run:
|
|
2868
|
+
npx -y calllint check <config-file-or-snippet>
|
|
2869
|
+
|
|
2870
|
+
Decision policy:
|
|
2871
|
+
- SAFE: continue only after stating that no blockers were observed.
|
|
2872
|
+
- REVIEW: explain the findings and ask before continuing.
|
|
2873
|
+
- BLOCK: stop. Do not proceed unless the user explicitly overrides.
|
|
2874
|
+
- UNKNOWN: do not auto-approve; ask for explicit review.
|
|
2875
|
+
- ERROR: do not silently continue.
|
|
2876
|
+
|
|
2877
|
+
Always:
|
|
2878
|
+
- Never execute a scanned MCP server just to decide whether it is safe.
|
|
2879
|
+
- Never print secret values.
|
|
2880
|
+
- Treat SAFE as "no blockers observed", not as proof of runtime safety.`;
|
|
2881
|
+
|
|
2882
|
+
// ../../packages/core/src/distribution/hostRules.ts
|
|
2883
|
+
var RULE_HOSTS = [
|
|
2884
|
+
"claude",
|
|
2885
|
+
"agents",
|
|
2886
|
+
"cursor",
|
|
2887
|
+
"copilot",
|
|
2888
|
+
"codex",
|
|
2889
|
+
"gemini",
|
|
2890
|
+
"windsurf",
|
|
2891
|
+
"cline",
|
|
2892
|
+
"command"
|
|
2893
|
+
];
|
|
2894
|
+
var RULE_TARGETS = {
|
|
2895
|
+
claude: { host: "claude", path: "CLAUDE.md", label: "Claude Code / Claude Desktop" },
|
|
2896
|
+
agents: { host: "agents", path: "AGENTS.md", label: "Generic coding/IDE/CI agents" },
|
|
2897
|
+
cursor: { host: "cursor", path: ".cursor/rules/calllint.mdc", label: "Cursor" },
|
|
2898
|
+
copilot: {
|
|
2899
|
+
host: "copilot",
|
|
2900
|
+
path: ".github/copilot-instructions.md",
|
|
2901
|
+
label: "VS Code / GitHub Copilot"
|
|
2902
|
+
},
|
|
2903
|
+
codex: { host: "codex", path: ".codex/AGENTS.md", label: "OpenAI Codex" },
|
|
2904
|
+
gemini: { host: "gemini", path: ".gemini/GEMINI.md", label: "Gemini CLI" },
|
|
2905
|
+
windsurf: { host: "windsurf", path: ".windsurf/rules/calllint.md", label: "Windsurf" },
|
|
2906
|
+
cline: { host: "cline", path: ".clinerules/calllint.md", label: "Cline" },
|
|
2907
|
+
command: { host: "command", path: ".claude/commands/calllint.md", label: "Claude slash command" }
|
|
2908
|
+
};
|
|
2909
|
+
function surfaceList() {
|
|
2910
|
+
return RELEVANT_SURFACES.map((s) => `- ${s}`).join("\n");
|
|
2911
|
+
}
|
|
2912
|
+
function rule() {
|
|
2913
|
+
return UNIVERSAL_AGENT_RULE;
|
|
2914
|
+
}
|
|
2915
|
+
function markdownBody(intro) {
|
|
2916
|
+
return `# CallLint Agent Tool Safety Rule
|
|
2917
|
+
|
|
2918
|
+
${intro}
|
|
2919
|
+
|
|
2920
|
+
Relevant files include:
|
|
2921
|
+
|
|
2922
|
+
${surfaceList()}
|
|
2923
|
+
|
|
2924
|
+
${rule()}
|
|
2925
|
+
`;
|
|
2926
|
+
}
|
|
2927
|
+
function renderHostRule(host) {
|
|
2928
|
+
switch (host) {
|
|
2929
|
+
case "claude":
|
|
2930
|
+
return markdownBody(
|
|
2931
|
+
"Treat MCP and agent-tool configuration as security-sensitive infrastructure in this repository."
|
|
2932
|
+
);
|
|
2933
|
+
case "agents":
|
|
2934
|
+
return markdownBody(
|
|
2935
|
+
"This rule applies to all coding agents, IDE agents, CI agents, and automation agents working in this repository. Treat MCP and agent-tool configuration as security-sensitive infrastructure."
|
|
2936
|
+
);
|
|
2937
|
+
case "copilot":
|
|
2938
|
+
return markdownBody(
|
|
2939
|
+
"When working in this repository, treat MCP and agent-tool configuration as security-sensitive infrastructure."
|
|
2940
|
+
);
|
|
2941
|
+
case "codex":
|
|
2942
|
+
case "gemini":
|
|
2943
|
+
case "windsurf":
|
|
2944
|
+
case "cline":
|
|
2945
|
+
return markdownBody(
|
|
2946
|
+
`Treat MCP and agent-tool configuration as security-sensitive infrastructure (${RULE_TARGETS[host].label}).`
|
|
2947
|
+
);
|
|
2948
|
+
case "cursor":
|
|
2949
|
+
return `---
|
|
2950
|
+
description: Require CallLint before MCP or agent-tool permission changes
|
|
2951
|
+
alwaysApply: true
|
|
2952
|
+
---
|
|
2953
|
+
|
|
2954
|
+
Treat MCP and agent-tool configuration as security-sensitive infrastructure.
|
|
2955
|
+
|
|
2956
|
+
Relevant files include:
|
|
2957
|
+
|
|
2958
|
+
${surfaceList()}
|
|
2959
|
+
|
|
2960
|
+
${rule()}
|
|
2961
|
+
`;
|
|
2962
|
+
case "command":
|
|
2963
|
+
return `# /calllint
|
|
2964
|
+
|
|
2965
|
+
Run CallLint against MCP and agent-tool configuration in this repository.
|
|
2966
|
+
|
|
2967
|
+
Steps:
|
|
2968
|
+
|
|
2969
|
+
1. Find relevant files:
|
|
2970
|
+
|
|
2971
|
+
${surfaceList()}
|
|
2972
|
+
|
|
2973
|
+
2. For each, run \`npx -y calllint check <file>\` (or \`calllint scan-all\` for the whole repo).
|
|
2974
|
+
3. Apply the decision policy below.
|
|
2975
|
+
|
|
2976
|
+
${rule()}
|
|
2977
|
+
`;
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2053
2981
|
// ../../packages/report-renderer/src/style.ts
|
|
2054
2982
|
var DEFAULT_STYLE = { emoji: true };
|
|
2055
2983
|
var NO_EMOJI_STYLE = { emoji: false };
|
|
@@ -2132,6 +3060,42 @@ function renderCompact(summary, style = DEFAULT_STYLE) {
|
|
|
2132
3060
|
return lines.join("\n");
|
|
2133
3061
|
}
|
|
2134
3062
|
|
|
3063
|
+
// ../../packages/report-renderer/src/renderDecision.ts
|
|
3064
|
+
var NEXT_ACTION_HINT = {
|
|
3065
|
+
continue: "No blockers observed \u2014 safe to continue.",
|
|
3066
|
+
ask_before_continue: "Review required \u2014 confirm before continuing.",
|
|
3067
|
+
stop: "Blocked \u2014 do not proceed without resolving the blocker.",
|
|
3068
|
+
gather_more_evidence: "Insufficient evidence \u2014 gather more before continuing."
|
|
3069
|
+
};
|
|
3070
|
+
function shortSurface(surface) {
|
|
3071
|
+
return surface.length > 60 ? "\u2026" + surface.slice(-59) : surface;
|
|
3072
|
+
}
|
|
3073
|
+
function renderDecision(decision, style = DEFAULT_STYLE) {
|
|
3074
|
+
const lines = [];
|
|
3075
|
+
lines.push(`${verdictTag(decision.verdict, style)} ${shortSurface(decision.surface)}`);
|
|
3076
|
+
if (decision.reasonCodes.length > 0) {
|
|
3077
|
+
lines.push(`Reasons: ${decision.reasonCodes.join(", ")}`);
|
|
3078
|
+
} else {
|
|
3079
|
+
lines.push("Reasons: none observed");
|
|
3080
|
+
}
|
|
3081
|
+
lines.push(`Next: ${NEXT_ACTION_HINT[decision.nextAction]}`);
|
|
3082
|
+
return lines.join("\n");
|
|
3083
|
+
}
|
|
3084
|
+
function renderDecisionTable(decisions, style = DEFAULT_STYLE) {
|
|
3085
|
+
if (decisions.length === 0) {
|
|
3086
|
+
return "0 agent-tool surfaces found";
|
|
3087
|
+
}
|
|
3088
|
+
const lines = [
|
|
3089
|
+
`${decisions.length} agent-tool surface${decisions.length === 1 ? "" : "s"} found`,
|
|
3090
|
+
""
|
|
3091
|
+
];
|
|
3092
|
+
for (const d of decisions) {
|
|
3093
|
+
const codes = d.reasonCodes.length > 0 ? d.reasonCodes.join(", ") : "no blockers observed";
|
|
3094
|
+
lines.push(`${verdictTag(d.verdict, style)} ${shortSurface(d.surface)} ${codes}`);
|
|
3095
|
+
}
|
|
3096
|
+
return lines.join("\n");
|
|
3097
|
+
}
|
|
3098
|
+
|
|
2135
3099
|
// ../../packages/report-renderer/src/renderExplain.ts
|
|
2136
3100
|
function renderFindingDetail(f, n) {
|
|
2137
3101
|
const lines = [];
|
|
@@ -2222,6 +3186,39 @@ function renderDrift(report) {
|
|
|
2222
3186
|
return lines.join("\n");
|
|
2223
3187
|
}
|
|
2224
3188
|
|
|
3189
|
+
// ../../packages/report-renderer/src/renderApprovedDrift.ts
|
|
3190
|
+
function renderApprovedDriftJson(report) {
|
|
3191
|
+
return JSON.stringify(report, null, 2);
|
|
3192
|
+
}
|
|
3193
|
+
var STATUS_TAG2 = {
|
|
3194
|
+
unchanged: "OK ",
|
|
3195
|
+
"hash-changed": "HASH ",
|
|
3196
|
+
"verdict-changed": "VRDCT",
|
|
3197
|
+
added: "ADD ",
|
|
3198
|
+
removed: "DEL "
|
|
3199
|
+
};
|
|
3200
|
+
function renderApprovedDrift(report) {
|
|
3201
|
+
const lines = [];
|
|
3202
|
+
lines.push("CallLint verify (drift vs approved state)");
|
|
3203
|
+
const headline = report.drifted ? `DRIFT \u2014 approved capability surface changed (${report.verdict})` : "no drift \u2014 matches approved state";
|
|
3204
|
+
lines.push(`result: ${headline}`);
|
|
3205
|
+
lines.push("\u2500".repeat(60));
|
|
3206
|
+
for (const e of report.entries) {
|
|
3207
|
+
const tag = STATUS_TAG2[e.status] ?? e.status;
|
|
3208
|
+
lines.push(`${tag} ${e.surface}`);
|
|
3209
|
+
if (e.status === "hash-changed") {
|
|
3210
|
+
lines.push(` \u2022 ${e.approvedHash} \u2192 ${e.currentHash}`);
|
|
3211
|
+
} else if (e.status === "verdict-changed") {
|
|
3212
|
+
lines.push(` \u2022 verdict ${e.approvedVerdict} \u2192 ${e.currentVerdict}`);
|
|
3213
|
+
} else if (e.status === "added") {
|
|
3214
|
+
lines.push(` \u2022 new surface, not in approved state (${e.currentVerdict})`);
|
|
3215
|
+
} else if (e.status === "removed") {
|
|
3216
|
+
lines.push(` \u2022 approved surface no longer present`);
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
return lines.join("\n");
|
|
3220
|
+
}
|
|
3221
|
+
|
|
2225
3222
|
// ../../packages/report-renderer/src/renderSarif.ts
|
|
2226
3223
|
function levelForSeverity(sev) {
|
|
2227
3224
|
if (sev === "critical" || sev === "high") return "error";
|
|
@@ -2400,6 +3397,32 @@ function mdCell(s) {
|
|
|
2400
3397
|
return s.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
2401
3398
|
}
|
|
2402
3399
|
|
|
3400
|
+
// ../../packages/report-renderer/src/renderBadge.ts
|
|
3401
|
+
var BADGE_COLOR = {
|
|
3402
|
+
SAFE: "brightgreen",
|
|
3403
|
+
REVIEW: "yellow",
|
|
3404
|
+
UNKNOWN: "lightgrey",
|
|
3405
|
+
BLOCK: "red"
|
|
3406
|
+
};
|
|
3407
|
+
var BADGE_MESSAGE = {
|
|
3408
|
+
SAFE: "SAFE",
|
|
3409
|
+
REVIEW: "REVIEW",
|
|
3410
|
+
UNKNOWN: "UNKNOWN",
|
|
3411
|
+
BLOCK: "BLOCK"
|
|
3412
|
+
};
|
|
3413
|
+
function renderBadge(summary) {
|
|
3414
|
+
return JSON.stringify(badgeEndpoint(summary.verdict), null, 2);
|
|
3415
|
+
}
|
|
3416
|
+
function badgeEndpoint(verdict) {
|
|
3417
|
+
return {
|
|
3418
|
+
schemaVersion: 1,
|
|
3419
|
+
label: "CallLint",
|
|
3420
|
+
message: BADGE_MESSAGE[verdict],
|
|
3421
|
+
color: BADGE_COLOR[verdict],
|
|
3422
|
+
cacheSeconds: 3600
|
|
3423
|
+
};
|
|
3424
|
+
}
|
|
3425
|
+
|
|
2403
3426
|
// ../../packages/report-renderer/src/renderDiagnostics.ts
|
|
2404
3427
|
function verdictContributionFor(f) {
|
|
2405
3428
|
if (f.blocker) return "blocker";
|
|
@@ -2596,8 +3619,8 @@ function exitCodeFor(summary, policy) {
|
|
|
2596
3619
|
}
|
|
2597
3620
|
|
|
2598
3621
|
// src/commands/resolveInput.ts
|
|
2599
|
-
import { existsSync as
|
|
2600
|
-
import { join as
|
|
3622
|
+
import { existsSync as existsSync3, readFileSync as readFileSync5 } from "node:fs";
|
|
3623
|
+
import { join as join4 } from "node:path";
|
|
2601
3624
|
var DEFAULT_CONFIG_PATHS = [
|
|
2602
3625
|
".cursor/mcp.json",
|
|
2603
3626
|
".mcp.json",
|
|
@@ -2607,8 +3630,8 @@ var DEFAULT_CONFIG_PATHS = [
|
|
|
2607
3630
|
];
|
|
2608
3631
|
function findDefaultConfig(cwd) {
|
|
2609
3632
|
for (const rel of DEFAULT_CONFIG_PATHS) {
|
|
2610
|
-
const p =
|
|
2611
|
-
if (
|
|
3633
|
+
const p = join4(cwd, rel);
|
|
3634
|
+
if (existsSync3(p)) return p;
|
|
2612
3635
|
}
|
|
2613
3636
|
return void 0;
|
|
2614
3637
|
}
|
|
@@ -2642,15 +3665,15 @@ function resolveConfigInput(args, deps) {
|
|
|
2642
3665
|
exitCode: EXIT.USAGE
|
|
2643
3666
|
};
|
|
2644
3667
|
}
|
|
2645
|
-
if (!
|
|
3668
|
+
if (!existsSync3(resolved)) {
|
|
2646
3669
|
return { error: `File not found: ${resolved}`, exitCode: EXIT.USAGE };
|
|
2647
3670
|
}
|
|
2648
|
-
return { text:
|
|
3671
|
+
return { text: readFileSync5(resolved, "utf8"), configPath: resolved };
|
|
2649
3672
|
}
|
|
2650
3673
|
|
|
2651
3674
|
// src/commands/changedConfigs.ts
|
|
2652
|
-
import { basename as
|
|
2653
|
-
import { existsSync as
|
|
3675
|
+
import { basename as basename3, resolve } from "node:path";
|
|
3676
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
2654
3677
|
function changedConfigPaths(cwd, diff) {
|
|
2655
3678
|
let raw;
|
|
2656
3679
|
try {
|
|
@@ -2658,27 +3681,27 @@ function changedConfigPaths(cwd, diff) {
|
|
|
2658
3681
|
} catch {
|
|
2659
3682
|
return [];
|
|
2660
3683
|
}
|
|
2661
|
-
const knownLeaves = new Set(DEFAULT_CONFIG_PATHS.map((p) =>
|
|
3684
|
+
const knownLeaves = new Set(DEFAULT_CONFIG_PATHS.map((p) => basename3(p)));
|
|
2662
3685
|
const knownPaths = new Set(DEFAULT_CONFIG_PATHS);
|
|
2663
3686
|
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(
|
|
2665
|
-
).map((f) => resolve(cwd, f)).filter((abs) =>
|
|
3687
|
+
(f) => f.length > 0 && (knownPaths.has(f) || DEFAULT_CONFIG_PATHS.some((p) => f.endsWith("/" + p)) || knownLeaves.has(basename3(f)))
|
|
3688
|
+
).map((f) => resolve(cwd, f)).filter((abs) => existsSync4(abs));
|
|
2666
3689
|
}
|
|
2667
3690
|
|
|
2668
3691
|
// src/commands/surfaces.ts
|
|
2669
|
-
import { existsSync as
|
|
2670
|
-
import { join as
|
|
3692
|
+
import { existsSync as existsSync5, readFileSync as readFileSync6, statSync as statSync2 } from "node:fs";
|
|
3693
|
+
import { join as join5 } from "node:path";
|
|
2671
3694
|
var SURFACE_SIZE_CAP = 256 * 1024;
|
|
2672
3695
|
var SURFACE_FILES = [
|
|
2673
3696
|
{ file: "README.md", kind: "readme" },
|
|
2674
3697
|
{ file: "SKILL.md", kind: "skill" },
|
|
2675
3698
|
{ file: "AGENTS.md", kind: "agents" }
|
|
2676
3699
|
];
|
|
2677
|
-
function
|
|
3700
|
+
function readCapped2(path) {
|
|
2678
3701
|
try {
|
|
2679
|
-
const st =
|
|
3702
|
+
const st = statSync2(path);
|
|
2680
3703
|
if (!st.isFile()) return void 0;
|
|
2681
|
-
const raw =
|
|
3704
|
+
const raw = readFileSync6(path, "utf8");
|
|
2682
3705
|
if (raw.length > SURFACE_SIZE_CAP) {
|
|
2683
3706
|
return { text: raw.slice(0, SURFACE_SIZE_CAP), truncated: true };
|
|
2684
3707
|
}
|
|
@@ -2689,13 +3712,13 @@ function readCapped(path) {
|
|
|
2689
3712
|
}
|
|
2690
3713
|
function readDocumentSurfaces(dir) {
|
|
2691
3714
|
const surfaces = [];
|
|
2692
|
-
if (!
|
|
3715
|
+
if (!existsSync5(dir)) return surfaces;
|
|
2693
3716
|
for (const { file, kind } of SURFACE_FILES) {
|
|
2694
|
-
const got =
|
|
3717
|
+
const got = readCapped2(join5(dir, file));
|
|
2695
3718
|
if (got) surfaces.push({ path: file, kind, text: got.text, truncated: got.truncated });
|
|
2696
3719
|
}
|
|
2697
|
-
const pkgPath =
|
|
2698
|
-
const pkgRaw =
|
|
3720
|
+
const pkgPath = join5(dir, "package.json");
|
|
3721
|
+
const pkgRaw = readCapped2(pkgPath);
|
|
2699
3722
|
if (pkgRaw) {
|
|
2700
3723
|
try {
|
|
2701
3724
|
const pkg = JSON.parse(pkgRaw.text);
|
|
@@ -2715,7 +3738,7 @@ function readDocumentSurfaces(dir) {
|
|
|
2715
3738
|
}
|
|
2716
3739
|
|
|
2717
3740
|
// src/commands/scan.ts
|
|
2718
|
-
import { readFileSync as
|
|
3741
|
+
import { readFileSync as readFileSync7 } from "node:fs";
|
|
2719
3742
|
function scanCommand(args, deps) {
|
|
2720
3743
|
if (flagBool(args.flags, "changed")) {
|
|
2721
3744
|
return scanChangedCommand(args, deps);
|
|
@@ -2762,7 +3785,7 @@ function scanOneConfig(text, configPath, policy, args, deps) {
|
|
|
2762
3785
|
}
|
|
2763
3786
|
if (deps.writeCacheFile !== false) {
|
|
2764
3787
|
try {
|
|
2765
|
-
writeCache(summary,
|
|
3788
|
+
writeCache(summary, join6(deps.cwd, ".calllint", "last-scan.json"));
|
|
2766
3789
|
} catch {
|
|
2767
3790
|
}
|
|
2768
3791
|
}
|
|
@@ -2775,6 +3798,7 @@ function renderSummary(summary, args) {
|
|
|
2775
3798
|
if (flagBool(args.flags, "json")) return renderJson(summary);
|
|
2776
3799
|
if (flagBool(args.flags, "sarif")) return renderSarif(summary);
|
|
2777
3800
|
if (flagBool(args.flags, "markdown")) return renderMarkdown(summary);
|
|
3801
|
+
if (flagBool(args.flags, "badge")) return renderBadge(summary);
|
|
2778
3802
|
if (flagBool(args.flags, "html")) return renderHtml(summary);
|
|
2779
3803
|
if (flagBool(args.flags, "compact")) return renderCompact(summary, style);
|
|
2780
3804
|
return renderTerminal(summary, style);
|
|
@@ -2806,7 +3830,7 @@ function scanChangedCommand(args, deps) {
|
|
|
2806
3830
|
};
|
|
2807
3831
|
}
|
|
2808
3832
|
const results = paths.map((p) => {
|
|
2809
|
-
const text =
|
|
3833
|
+
const text = readFileSync7(p, "utf8");
|
|
2810
3834
|
return scanOneConfig(text, p, policy, args, deps);
|
|
2811
3835
|
});
|
|
2812
3836
|
const exitCode = results.reduce((worst, r) => Math.max(worst, r.exitCode), EXIT.OK);
|
|
@@ -2820,6 +3844,143 @@ function scanChangedCommand(args, deps) {
|
|
|
2820
3844
|
return { stdout, exitCode, ...stderr ? { stderr } : {} };
|
|
2821
3845
|
}
|
|
2822
3846
|
|
|
3847
|
+
// src/commands/check.ts
|
|
3848
|
+
function checkCommand(args, deps) {
|
|
3849
|
+
const style = flagBool(args.flags, "no-emoji") ? NO_EMOJI_STYLE : DEFAULT_STYLE;
|
|
3850
|
+
const opts = { now: deps.now, generatedAt: deps.generatedAt };
|
|
3851
|
+
const input = resolveConfigInput(args, deps);
|
|
3852
|
+
if (isInputError(input)) {
|
|
3853
|
+
return { stdout: "", stderr: input.error, exitCode: input.exitCode };
|
|
3854
|
+
}
|
|
3855
|
+
let decisions;
|
|
3856
|
+
try {
|
|
3857
|
+
const looksLikeJson = input.text.trim().startsWith("{");
|
|
3858
|
+
if (input.configPath === "<stdin>" && !looksLikeJson) {
|
|
3859
|
+
const { parsed } = parseSnippet(input.text);
|
|
3860
|
+
decisions = checkParsed(parsed, "stdin:snippet", "remote", opts);
|
|
3861
|
+
} else {
|
|
3862
|
+
const loaded = loadSurfaceText(input.text, input.configPath);
|
|
3863
|
+
decisions = checkParsed(loaded.parsed, input.configPath, loaded.origin, opts);
|
|
3864
|
+
}
|
|
3865
|
+
} catch (e) {
|
|
3866
|
+
if (e instanceof ConfigParseError) {
|
|
3867
|
+
return { stdout: "", stderr: `Parse error: ${e.message}`, exitCode: EXIT.ERROR };
|
|
3868
|
+
}
|
|
3869
|
+
if (e instanceof Error) {
|
|
3870
|
+
return { stdout: "", stderr: e.message, exitCode: EXIT.UNKNOWN };
|
|
3871
|
+
}
|
|
3872
|
+
throw e;
|
|
3873
|
+
}
|
|
3874
|
+
if (decisions.length === 0) {
|
|
3875
|
+
return {
|
|
3876
|
+
stdout: "UNKNOWN no agent-tool capability found in input",
|
|
3877
|
+
exitCode: EXIT.UNKNOWN
|
|
3878
|
+
};
|
|
3879
|
+
}
|
|
3880
|
+
if (flagBool(args.flags, "explain") || flagBool(args.flags, "full")) {
|
|
3881
|
+
const stdout2 = decisions.map((d) => renderExplain(d.report, style)).join("\n\n");
|
|
3882
|
+
return { stdout: stdout2, exitCode: worstExit(decisions) };
|
|
3883
|
+
}
|
|
3884
|
+
if (flagBool(args.flags, "json")) {
|
|
3885
|
+
const payload = decisions.length === 1 ? decisions[0].decision : decisions.map((d) => d.decision);
|
|
3886
|
+
return { stdout: JSON.stringify(payload), exitCode: worstExit(decisions) };
|
|
3887
|
+
}
|
|
3888
|
+
const stdout = decisions.map((d) => renderDecision(d.decision, style)).join("\n\n");
|
|
3889
|
+
return { stdout, exitCode: worstExit(decisions) };
|
|
3890
|
+
}
|
|
3891
|
+
function worstExit(decisions) {
|
|
3892
|
+
let worst = EXIT.OK;
|
|
3893
|
+
for (const d of decisions) {
|
|
3894
|
+
const code = exitForVerdict(d.decision.verdict);
|
|
3895
|
+
if (code > worst) worst = code;
|
|
3896
|
+
}
|
|
3897
|
+
return worst;
|
|
3898
|
+
}
|
|
3899
|
+
function exitForVerdict(verdict) {
|
|
3900
|
+
switch (verdict) {
|
|
3901
|
+
case "BLOCK":
|
|
3902
|
+
return EXIT.BLOCK;
|
|
3903
|
+
case "UNKNOWN":
|
|
3904
|
+
return EXIT.UNKNOWN;
|
|
3905
|
+
case "REVIEW":
|
|
3906
|
+
return EXIT.REVIEW;
|
|
3907
|
+
case "SAFE":
|
|
3908
|
+
return EXIT.OK;
|
|
3909
|
+
}
|
|
3910
|
+
}
|
|
3911
|
+
|
|
3912
|
+
// src/commands/scanAll.ts
|
|
3913
|
+
function scanAllCommand(args, deps) {
|
|
3914
|
+
const style = flagBool(args.flags, "no-emoji") ? NO_EMOJI_STYLE : DEFAULT_STYLE;
|
|
3915
|
+
const decisions = decideRepoSurfaces(deps.cwd, {
|
|
3916
|
+
now: deps.now,
|
|
3917
|
+
generatedAt: deps.generatedAt
|
|
3918
|
+
});
|
|
3919
|
+
if (flagBool(args.flags, "json")) {
|
|
3920
|
+
return { stdout: JSON.stringify(decisions), exitCode: worstExit2(decisions) };
|
|
3921
|
+
}
|
|
3922
|
+
return { stdout: renderDecisionTable(decisions, style), exitCode: worstExit2(decisions) };
|
|
3923
|
+
}
|
|
3924
|
+
function worstExit2(decisions) {
|
|
3925
|
+
let worst = EXIT.OK;
|
|
3926
|
+
for (const d of decisions) {
|
|
3927
|
+
const code = d.verdict === "BLOCK" ? EXIT.BLOCK : d.verdict === "UNKNOWN" ? EXIT.UNKNOWN : d.verdict === "REVIEW" ? EXIT.REVIEW : EXIT.OK;
|
|
3928
|
+
if (code > worst) worst = code;
|
|
3929
|
+
}
|
|
3930
|
+
return worst;
|
|
3931
|
+
}
|
|
3932
|
+
|
|
3933
|
+
// src/commands/genRule.ts
|
|
3934
|
+
import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync3 } from "node:fs";
|
|
3935
|
+
import { dirname as dirname3, join as join7, resolve as resolve3 } from "node:path";
|
|
3936
|
+
function isRuleHost(v) {
|
|
3937
|
+
return v !== void 0 && RULE_HOSTS.includes(v);
|
|
3938
|
+
}
|
|
3939
|
+
function genRuleCommand(args, deps) {
|
|
3940
|
+
const host = flagStr(args.flags, "host");
|
|
3941
|
+
if (!host) {
|
|
3942
|
+
const list = RULE_HOSTS.map(
|
|
3943
|
+
(h) => ` ${h.padEnd(10)} \u2192 ${RULE_TARGETS[h].path} (${RULE_TARGETS[h].label})`
|
|
3944
|
+
).join("\n");
|
|
3945
|
+
return {
|
|
3946
|
+
stdout: `Usage: calllint gen-rule --host <host> [--write] [--out <path>]
|
|
3947
|
+
|
|
3948
|
+
Hosts:
|
|
3949
|
+
${list}`,
|
|
3950
|
+
exitCode: EXIT.OK
|
|
3951
|
+
};
|
|
3952
|
+
}
|
|
3953
|
+
if (!isRuleHost(host)) {
|
|
3954
|
+
return {
|
|
3955
|
+
stdout: "",
|
|
3956
|
+
stderr: `Unknown host: ${host}
|
|
3957
|
+
Run \`calllint gen-rule\` to list hosts.`,
|
|
3958
|
+
exitCode: EXIT.USAGE
|
|
3959
|
+
};
|
|
3960
|
+
}
|
|
3961
|
+
const content = renderHostRule(host);
|
|
3962
|
+
if (!flagBool(args.flags, "write")) {
|
|
3963
|
+
return { stdout: content, exitCode: EXIT.OK };
|
|
3964
|
+
}
|
|
3965
|
+
const rel = flagStr(args.flags, "out") ?? RULE_TARGETS[host].path;
|
|
3966
|
+
const abs = resolve3(deps.cwd, rel);
|
|
3967
|
+
const write = deps.writeFile ?? defaultWrite;
|
|
3968
|
+
try {
|
|
3969
|
+
write(abs, content);
|
|
3970
|
+
} catch (e) {
|
|
3971
|
+
return {
|
|
3972
|
+
stdout: "",
|
|
3973
|
+
stderr: `Failed to write ${rel}: ${e instanceof Error ? e.message : String(e)}`,
|
|
3974
|
+
exitCode: EXIT.ERROR
|
|
3975
|
+
};
|
|
3976
|
+
}
|
|
3977
|
+
return { stdout: `Wrote ${rel} (${RULE_TARGETS[host].label})`, exitCode: EXIT.OK };
|
|
3978
|
+
}
|
|
3979
|
+
function defaultWrite(path, content) {
|
|
3980
|
+
mkdirSync3(dirname3(path), { recursive: true });
|
|
3981
|
+
writeFileSync3(path, content, "utf8");
|
|
3982
|
+
}
|
|
3983
|
+
|
|
2823
3984
|
// src/commands/diagnostics.ts
|
|
2824
3985
|
function diagnosticsCommand(args, deps) {
|
|
2825
3986
|
if (!flagBool(args.flags, "json")) {
|
|
@@ -2868,7 +4029,7 @@ function diagnosticsCommand(args, deps) {
|
|
|
2868
4029
|
}
|
|
2869
4030
|
|
|
2870
4031
|
// src/commands/explain.ts
|
|
2871
|
-
import { join as
|
|
4032
|
+
import { join as join8 } from "node:path";
|
|
2872
4033
|
function explainCommand(args, deps) {
|
|
2873
4034
|
const serverName = args.positionals[0];
|
|
2874
4035
|
if (!serverName) {
|
|
@@ -2878,7 +4039,7 @@ function explainCommand(args, deps) {
|
|
|
2878
4039
|
exitCode: EXIT.USAGE
|
|
2879
4040
|
};
|
|
2880
4041
|
}
|
|
2881
|
-
const summary = readCache(
|
|
4042
|
+
const summary = readCache(join8(deps.cwd, ".calllint", "last-scan.json"));
|
|
2882
4043
|
if (!summary) {
|
|
2883
4044
|
return {
|
|
2884
4045
|
stdout: "",
|
|
@@ -2900,20 +4061,20 @@ function explainCommand(args, deps) {
|
|
|
2900
4061
|
}
|
|
2901
4062
|
|
|
2902
4063
|
// src/commands/policy.ts
|
|
2903
|
-
import { existsSync as
|
|
2904
|
-
import { join as
|
|
4064
|
+
import { existsSync as existsSync6, writeFileSync as writeFileSync4 } from "node:fs";
|
|
4065
|
+
import { join as join9 } from "node:path";
|
|
2905
4066
|
function policyCommand(args, deps) {
|
|
2906
4067
|
const sub = args.positionals[0];
|
|
2907
4068
|
if (sub === "init") {
|
|
2908
|
-
const path =
|
|
2909
|
-
if (
|
|
4069
|
+
const path = join9(deps.cwd, "calllint.policy.json");
|
|
4070
|
+
if (existsSync6(path) && !flagBool(args.flags, "force")) {
|
|
2910
4071
|
return {
|
|
2911
4072
|
stdout: "",
|
|
2912
4073
|
stderr: `${path} already exists. Use --force to overwrite.`,
|
|
2913
4074
|
exitCode: EXIT.USAGE
|
|
2914
4075
|
};
|
|
2915
4076
|
}
|
|
2916
|
-
|
|
4077
|
+
writeFileSync4(path, defaultPolicyJson(), "utf8");
|
|
2917
4078
|
return { stdout: `Wrote default policy to ${path}`, exitCode: EXIT.OK };
|
|
2918
4079
|
}
|
|
2919
4080
|
if (sub === "explain") {
|
|
@@ -2936,7 +4097,7 @@ function policyCommand(args, deps) {
|
|
|
2936
4097
|
}
|
|
2937
4098
|
|
|
2938
4099
|
// src/commands/verify.ts
|
|
2939
|
-
import { join as
|
|
4100
|
+
import { join as join10 } from "node:path";
|
|
2940
4101
|
function loadPolicy(args) {
|
|
2941
4102
|
try {
|
|
2942
4103
|
return loadPolicyOrDefault(flagStr(args.flags, "policy"));
|
|
@@ -2945,7 +4106,7 @@ function loadPolicy(args) {
|
|
|
2945
4106
|
}
|
|
2946
4107
|
}
|
|
2947
4108
|
function baselinePathFor(args, cwd) {
|
|
2948
|
-
return flagStr(args.flags, "baseline") ??
|
|
4109
|
+
return flagStr(args.flags, "baseline") ?? join10(cwd, ".calllint", "baseline.json");
|
|
2949
4110
|
}
|
|
2950
4111
|
function baselineCommand(args, deps) {
|
|
2951
4112
|
const policy = loadPolicy(args);
|
|
@@ -2984,6 +4145,9 @@ function baselineCommand(args, deps) {
|
|
|
2984
4145
|
};
|
|
2985
4146
|
}
|
|
2986
4147
|
function verifyCommand(args, deps) {
|
|
4148
|
+
if (flagBool(args.flags, "approved")) {
|
|
4149
|
+
return verifyApprovedMode(args, deps);
|
|
4150
|
+
}
|
|
2987
4151
|
const policy = loadPolicy(args);
|
|
2988
4152
|
if ("error" in policy) return { stdout: "", stderr: policy.error, exitCode: EXIT.ERROR };
|
|
2989
4153
|
const path = baselinePathFor(args, deps.cwd);
|
|
@@ -3015,6 +4179,57 @@ function verifyCommand(args, deps) {
|
|
|
3015
4179
|
const exitCode = flagBool(args.flags, "ci") && drift.drifted ? EXIT.DRIFT : EXIT.OK;
|
|
3016
4180
|
return { stdout, exitCode };
|
|
3017
4181
|
}
|
|
4182
|
+
function approvedPathFor(args, cwd) {
|
|
4183
|
+
const flag = flagStr(args.flags, "approved");
|
|
4184
|
+
return typeof flag === "string" && flag.length > 0 ? flag : defaultApprovedPath(cwd);
|
|
4185
|
+
}
|
|
4186
|
+
function verifyApprovedMode(args, deps) {
|
|
4187
|
+
const path = approvedPathFor(args, deps.cwd);
|
|
4188
|
+
const approved = readApproved(path);
|
|
4189
|
+
if (!approved) {
|
|
4190
|
+
return {
|
|
4191
|
+
stdout: "",
|
|
4192
|
+
stderr: `No approved state found at ${path}. Run \`calllint approve\` first.`,
|
|
4193
|
+
exitCode: EXIT.ERROR
|
|
4194
|
+
};
|
|
4195
|
+
}
|
|
4196
|
+
const current = decideRepoSurfaces(deps.cwd, {
|
|
4197
|
+
now: deps.now ?? (Date.parse(deps.generatedAt) || 0),
|
|
4198
|
+
generatedAt: deps.generatedAt
|
|
4199
|
+
});
|
|
4200
|
+
const drift = verifyApproved(current, approved, deps.generatedAt);
|
|
4201
|
+
const stdout = flagBool(args.flags, "json") ? renderApprovedDriftJson(drift) : renderApprovedDrift(drift);
|
|
4202
|
+
const exitCode = flagBool(args.flags, "ci") && drift.drifted ? EXIT.DRIFT : EXIT.OK;
|
|
4203
|
+
return { stdout, exitCode };
|
|
4204
|
+
}
|
|
4205
|
+
|
|
4206
|
+
// src/commands/approve.ts
|
|
4207
|
+
function approvedPathFor2(args, cwd) {
|
|
4208
|
+
return flagStr(args.flags, "approved") ?? defaultApprovedPath(cwd);
|
|
4209
|
+
}
|
|
4210
|
+
function approveCommand(args, deps) {
|
|
4211
|
+
const decisions = decideRepoSurfaces(deps.cwd, {
|
|
4212
|
+
now: deps.now,
|
|
4213
|
+
generatedAt: deps.generatedAt
|
|
4214
|
+
});
|
|
4215
|
+
const state = buildApproved(decisions, deps.generatedAt);
|
|
4216
|
+
const path = approvedPathFor2(args, deps.cwd);
|
|
4217
|
+
if (deps.writeFile !== false) {
|
|
4218
|
+
try {
|
|
4219
|
+
writeApproved(state, path);
|
|
4220
|
+
} catch (err) {
|
|
4221
|
+
return { stdout: "", stderr: `Could not write approved state: ${String(err)}`, exitCode: EXIT.ERROR };
|
|
4222
|
+
}
|
|
4223
|
+
}
|
|
4224
|
+
if (flagBool(args.flags, "json")) {
|
|
4225
|
+
return { stdout: JSON.stringify(state, null, 2), exitCode: EXIT.OK };
|
|
4226
|
+
}
|
|
4227
|
+
const n = state.approved.length;
|
|
4228
|
+
return {
|
|
4229
|
+
stdout: `Approved ${n} surface${n === 1 ? "" : "s"} \u2192 ${path}`,
|
|
4230
|
+
exitCode: EXIT.OK
|
|
4231
|
+
};
|
|
4232
|
+
}
|
|
3018
4233
|
|
|
3019
4234
|
// src/run.ts
|
|
3020
4235
|
function run(argv, deps) {
|
|
@@ -3024,6 +4239,19 @@ function run(argv, deps) {
|
|
|
3024
4239
|
return helpCommand();
|
|
3025
4240
|
}
|
|
3026
4241
|
switch (cmd) {
|
|
4242
|
+
case "check":
|
|
4243
|
+
return checkCommand(args, {
|
|
4244
|
+
cwd: deps.cwd,
|
|
4245
|
+
readStdin: deps.readStdin,
|
|
4246
|
+
now: deps.now,
|
|
4247
|
+
generatedAt: deps.generatedAt
|
|
4248
|
+
});
|
|
4249
|
+
case "scan-all":
|
|
4250
|
+
return scanAllCommand(args, {
|
|
4251
|
+
cwd: deps.cwd,
|
|
4252
|
+
now: deps.now,
|
|
4253
|
+
generatedAt: deps.generatedAt
|
|
4254
|
+
});
|
|
3027
4255
|
case "scan":
|
|
3028
4256
|
return scanCommand(args, {
|
|
3029
4257
|
cwd: deps.cwd,
|
|
@@ -3054,12 +4282,22 @@ function run(argv, deps) {
|
|
|
3054
4282
|
return verifyCommand(args, {
|
|
3055
4283
|
cwd: deps.cwd,
|
|
3056
4284
|
readStdin: deps.readStdin,
|
|
4285
|
+
now: deps.now,
|
|
3057
4286
|
generatedAt: deps.generatedAt,
|
|
3058
4287
|
writeBaselineFile: deps.writeCacheFile,
|
|
3059
4288
|
online: deps.online
|
|
3060
4289
|
});
|
|
4290
|
+
case "approve":
|
|
4291
|
+
return approveCommand(args, {
|
|
4292
|
+
cwd: deps.cwd,
|
|
4293
|
+
now: deps.now,
|
|
4294
|
+
generatedAt: deps.generatedAt,
|
|
4295
|
+
writeFile: deps.writeCacheFile
|
|
4296
|
+
});
|
|
3061
4297
|
case "explain":
|
|
3062
4298
|
return explainCommand(args, { cwd: deps.cwd });
|
|
4299
|
+
case "gen-rule":
|
|
4300
|
+
return genRuleCommand(args, { cwd: deps.cwd });
|
|
3063
4301
|
case "policy":
|
|
3064
4302
|
return policyCommand(args, { cwd: deps.cwd });
|
|
3065
4303
|
default:
|
|
@@ -3306,7 +4544,7 @@ async function breathe(argv, deps = {}) {
|
|
|
3306
4544
|
// src/index.ts
|
|
3307
4545
|
function readStdin() {
|
|
3308
4546
|
try {
|
|
3309
|
-
return
|
|
4547
|
+
return readFileSync8(0, "utf8");
|
|
3310
4548
|
} catch {
|
|
3311
4549
|
return "";
|
|
3312
4550
|
}
|