calllint 1.4.0 → 1.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 +663 -13
  2. package/package.json +2 -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 readFileSync20 } from "node:fs";
4
+ import { readFileSync as readFileSync21 } from "node:fs";
5
5
  import { execFileSync } from "node:child_process";
6
6
 
7
7
  // src/args.ts
@@ -74,6 +74,10 @@ COMMANDS
74
74
  diagnostics [target] Emit editor/agent-host diagnostics JSON (calllint.diagnostics.v0)
75
75
  baseline [target] Record the approved risk surface as a baseline
76
76
  approve Record the repo-wide capability surface as approved state (L4)
77
+ guard Continuous Guard: re-decide on authority change; silent when unchanged
78
+ guard install --host <h> Install a guard hook (git pre-commit, github workflow)
79
+ guard status Show baseline / disable / installed-hook state
80
+ guard disable Turn Continuous Guard off (.calllint/guard.json)
77
81
  receipt verify <f> Validate a calllint.receipt.v0 (structure + signature if present)
78
82
  receipt sign <f> Sign a receipt with a local key (--key; development/testing)
79
83
  receipt keygen Generate a local ed25519 keypair (--out; development/testing)
@@ -122,6 +126,13 @@ VERIFY OPTIONS
122
126
  --ci Exit 40 if the surface drifted (from baseline or approved state)
123
127
  --json Emit the drift report JSON
124
128
 
129
+ GUARD OPTIONS
130
+ --host <h> guard install target: git (pre-commit) | github (workflow)
131
+ --approved [file] Approved baseline to diff against (default: .calllint/approved.json)
132
+ --json Emit the guard assessment / status JSON
133
+ (exit) silent/note=0, REVIEW=10, UNKNOWN=20, BLOCK=30; guard self-failure is non-zero
134
+ (env) CALLLINT_GUARD=0 disables the guard
135
+
125
136
  EXAMPLES
126
137
  calllint inventory # List discovered agent configs
127
138
  calllint scan --auto # Discover and scan all agents
@@ -140,6 +151,8 @@ EXAMPLES
140
151
  calllint inbox inspect gmail-reply.normalized.json
141
152
  calllint verify ./mcp.json --ci
142
153
  calllint approve && calllint verify --approved --ci
154
+ calllint approve && calllint guard install --host git # re-decide on every commit
155
+ calllint guard --json # authority-change assessment (silent when unchanged)
143
156
  calllint explain filesystem
144
157
  `;
145
158
  function helpCommand() {
@@ -407,7 +420,10 @@ var REASON_CODES = [
407
420
  "PROMPT_METADATA_INSTRUCTION",
408
421
  "OAUTH_SCOPE_UNKNOWN_OR_EXPANDED",
409
422
  "TOOL_DESCRIPTOR_CHANGED",
410
- "LONG_RUNNING_GATEWAY_RUNTIME"
423
+ "LONG_RUNNING_GATEWAY_RUNTIME",
424
+ // #13 — appended last (ADR 0044) so the frozen order 0–11 is unchanged. Backed by
425
+ // the flow object (calllint.flow.v0), not a static detector; see REASON_CODE_META.
426
+ "TOXIC_FLOW_COMPOSITION"
411
427
  ];
412
428
  var REASON_CODE_META = {
413
429
  UNPINNED_PACKAGE: {
@@ -471,6 +487,16 @@ var REASON_CODE_META = {
471
487
  backedBy: ["runtime.gateway"],
472
488
  status: "wired",
473
489
  label: "Long-running gateway runtime"
490
+ },
491
+ TOXIC_FLOW_COMPOSITION: {
492
+ // Backed by the flow object (calllint.flow.v0, built by the flow-analyzer package),
493
+ // NOT a static detector Finding. The synthetic backing id keeps every wired code
494
+ // naming its backing without inventing a new status value. No detector emits this id,
495
+ // so findingsToReasonCodes never fabricates the code — it is produced only by the
496
+ // explicit flow-fold step. See ADR 0044 / 0040.
497
+ backedBy: ["flow:toxic-composition"],
498
+ status: "wired",
499
+ label: "Cross-tool toxic-flow composition"
474
500
  }
475
501
  };
476
502
  function reasonCodeForFinding(findingId) {
@@ -491,6 +517,9 @@ var VERDICT_NEXT_ACTION = {
491
517
  // ../../packages/types/src/authority.ts
492
518
  var AUTHORITY_SCHEMA_VERSION = "calllint.authority.v0";
493
519
 
520
+ // ../../packages/types/src/flow.ts
521
+ var FLOW_SCHEMA_VERSION = "calllint.flow.v0";
522
+
494
523
  // ../../packages/types/src/trustDecision.ts
495
524
  var DECISION_SCHEMA_VERSION = "calllint.decision.v0";
496
525
 
@@ -624,11 +653,13 @@ function evidenceFloor(evidence) {
624
653
  function decideOverAuthority(input) {
625
654
  const { authority, policy } = input;
626
655
  const evidence = input.evidence ?? [];
627
- const reasons = authority.capabilities.map((c) => {
656
+ const capabilityReasons = authority.capabilities.map((c) => {
628
657
  const code = reasonCodeFor(c);
629
658
  const contributes = moreSevere(baseVerdict(c), policyFloor(code, policy));
630
659
  return { code, evidenceSource: c.evidenceSource, contributes };
631
660
  });
661
+ const flowReasons = input.flowReasons ?? [];
662
+ const reasons = [...capabilityReasons, ...flowReasons];
632
663
  const unknowns = [...authority.unknowns];
633
664
  const contributions = reasons.map((r) => r.contributes);
634
665
  if (authority.subject.artifactDigest === null) {
@@ -1863,6 +1894,21 @@ function analyzeDocumentSurfaces(surfaces) {
1863
1894
  ];
1864
1895
  }
1865
1896
 
1897
+ // ../../packages/static-analyzer/src/trustSource.ts
1898
+ function classifyTrustSource(cap) {
1899
+ if (cap.action === "read" && cap.resource === "secret") return "sensitive.secret";
1900
+ if (cap.action === "execute" && cap.resource === "process" && cap.evidenceSource === "server.command") {
1901
+ return "trusted.local_project";
1902
+ }
1903
+ return "unknown";
1904
+ }
1905
+ function withTrustSource(caps) {
1906
+ return caps.map((c) => {
1907
+ const ts = classifyTrustSource(c);
1908
+ return ts === "unknown" ? { ...c } : { ...c, trustSource: ts };
1909
+ });
1910
+ }
1911
+
1866
1912
  // ../../packages/static-analyzer/src/instructionAuthority.ts
1867
1913
  var RULES = [
1868
1914
  // 1. privilege-escalation → execute × process (irreversible, must be approved)
@@ -2068,7 +2114,7 @@ function extractInstructionAuthority(surfaces) {
2068
2114
  }
2069
2115
  }
2070
2116
  }
2071
- return sortCapabilities([...seen.values()]);
2117
+ return withTrustSource(sortCapabilities([...seen.values()]));
2072
2118
  }
2073
2119
  function sortCapabilities(caps) {
2074
2120
  return caps.sort((a, b) => {
@@ -2155,7 +2201,7 @@ function deriveConfigCapabilities(server) {
2155
2201
  completeness: "complete"
2156
2202
  });
2157
2203
  }
2158
- return sortCapabilities(caps);
2204
+ return sortCapabilities(withTrustSource(caps));
2159
2205
  }
2160
2206
 
2161
2207
  // ../../packages/risk-engine/src/computeRiskClass.ts
@@ -3366,6 +3412,60 @@ function escalate(current) {
3366
3412
  return current === "BLOCK" || current === "UNKNOWN" ? current : "REVIEW";
3367
3413
  }
3368
3414
 
3415
+ // ../../packages/core/src/state/continuousGuard.ts
3416
+ function assessGuardDrift(report) {
3417
+ if (!report.drifted) {
3418
+ return {
3419
+ action: "silent",
3420
+ verdict: "SAFE",
3421
+ drifted: false,
3422
+ summary: "No authority change since the approved baseline."
3423
+ };
3424
+ }
3425
+ const changed = report.entries.filter((e) => e.status !== "unchanged");
3426
+ const n = changed.length;
3427
+ const surfaces = `${n} surface${n === 1 ? "" : "s"}`;
3428
+ switch (report.verdict) {
3429
+ case "BLOCK":
3430
+ return {
3431
+ action: "refuse",
3432
+ verdict: "BLOCK",
3433
+ drifted: true,
3434
+ summary: `Authority changed on ${surfaces}; the new surface is blocked by policy.`
3435
+ };
3436
+ case "UNKNOWN":
3437
+ return {
3438
+ action: "request-evidence",
3439
+ verdict: "UNKNOWN",
3440
+ drifted: true,
3441
+ summary: `Authority changed on ${surfaces}; the new surface could not be verified \u2014 evidence or approval required.`
3442
+ };
3443
+ case "REVIEW":
3444
+ return {
3445
+ action: "prompt",
3446
+ verdict: "REVIEW",
3447
+ drifted: true,
3448
+ summary: `Authority changed on ${surfaces}; review the new surface before proceeding.`
3449
+ };
3450
+ case "SAFE":
3451
+ return {
3452
+ action: "note",
3453
+ verdict: "SAFE",
3454
+ drifted: true,
3455
+ summary: `Authority changed on ${surfaces}; no new blockers observed.`
3456
+ };
3457
+ }
3458
+ }
3459
+ function guardFailClosed(failure) {
3460
+ return {
3461
+ action: "fail-closed",
3462
+ verdict: "UNKNOWN",
3463
+ drifted: false,
3464
+ summary: "Continuous Guard could not verify the authority surface; failing closed.",
3465
+ failure
3466
+ };
3467
+ }
3468
+
3369
3469
  // ../../packages/core/src/distribution/agentRule.ts
3370
3470
  var RELEVANT_SURFACES = [
3371
3471
  ".cursor/mcp.json",
@@ -3499,6 +3599,64 @@ ${rule()}
3499
3599
  }
3500
3600
  }
3501
3601
 
3602
+ // ../../packages/core/src/distribution/ciGate.ts
3603
+ var CI_GATE_PATHS = [
3604
+ "**/mcp.json",
3605
+ "**/.mcp.json",
3606
+ "**/settings.json",
3607
+ "**/config.toml",
3608
+ ".cursor/**",
3609
+ ".vscode/**",
3610
+ "claude_desktop_config.json",
3611
+ "CLAUDE.md",
3612
+ "AGENTS.md",
3613
+ ".calllint/approved.json"
3614
+ ];
3615
+ function pathsBlock() {
3616
+ return CI_GATE_PATHS.map((p) => ` - ${JSON.stringify(p)}`).join("\n");
3617
+ }
3618
+ var DRIFT_STEP = ` - name: CallLint drift gate
3619
+ shell: bash
3620
+ run: |
3621
+ if [ -f .calllint/approved.json ]; then
3622
+ npx -y calllint verify --approved --ci --no-emoji
3623
+ else
3624
+ echo "No .calllint/approved.json \u2014 seed it with: npx calllint approve"
3625
+ echo "Running report-only scan-all (not gating)."
3626
+ npx -y calllint scan-all --no-emoji || true
3627
+ fi`;
3628
+ var SCAN_ALL_STEP = ` - name: CallLint scan-all (report-only)
3629
+ shell: bash
3630
+ run: npx -y calllint scan-all --no-emoji || true`;
3631
+ function renderCiGate(opts = {}) {
3632
+ const mode = opts.mode ?? "drift";
3633
+ const step = mode === "scan-all" ? SCAN_ALL_STEP : DRIFT_STEP;
3634
+ const headline = mode === "scan-all" ? "CallLint agent-tool surface report (report-only; never gates)." : "CallLint agent-tool surface drift gate (fails on approved-state drift).";
3635
+ return `# ${headline}
3636
+ # Generated by \`calllint\` (generate_ci_gate_snippet). CallLint is static and
3637
+ # NEVER executes a scanned server.
3638
+ name: calllint
3639
+
3640
+ on:
3641
+ pull_request:
3642
+ paths:
3643
+ ${pathsBlock()}
3644
+
3645
+ permissions:
3646
+ contents: read
3647
+
3648
+ jobs:
3649
+ surface-gate:
3650
+ runs-on: ubuntu-latest
3651
+ steps:
3652
+ - uses: actions/checkout@v6
3653
+ - uses: actions/setup-node@v6
3654
+ with:
3655
+ node-version: 20
3656
+ ${step}
3657
+ `;
3658
+ }
3659
+
3502
3660
  // ../../packages/core/src/receipt/createReceipt.ts
3503
3661
  import { randomBytes } from "node:crypto";
3504
3662
  function newReceiptId() {
@@ -7918,6 +8076,235 @@ import { existsSync as existsSync11, mkdirSync as mkdirSync5, readdirSync as rea
7918
8076
  import { basename as basename4, join as join16, resolve as resolvePath4 } from "node:path";
7919
8077
  import { homedir, userInfo } from "node:os";
7920
8078
 
8079
+ // ../../packages/flow-analyzer/src/flowRules.ts
8080
+ var UNTRUSTED_OR_SENSITIVE = /* @__PURE__ */ new Set([
8081
+ "sensitive.secret",
8082
+ "sensitive.private_data",
8083
+ "untrusted.public_content",
8084
+ "untrusted.tool_output",
8085
+ "untrusted.peer_agent",
8086
+ "untrusted.memory"
8087
+ ]);
8088
+ var TRUSTED = /* @__PURE__ */ new Set([
8089
+ "trusted.policy",
8090
+ "trusted.user_explicit",
8091
+ "trusted.local_project",
8092
+ "trusted.signed_component"
8093
+ ]);
8094
+ function isUntrustedOrSensitive(ts) {
8095
+ return ts !== void 0 && UNTRUSTED_OR_SENSITIVE.has(ts);
8096
+ }
8097
+ function isTrusted(ts) {
8098
+ return ts !== void 0 && TRUSTED.has(ts);
8099
+ }
8100
+ function sinkKind(sink) {
8101
+ const key = `${sink.action}:${sink.resource}`;
8102
+ if (key === "send:network" || key === "connect:network") return "network";
8103
+ if (key === "send:message") return "message";
8104
+ if (key === "spend:financial") return "financial";
8105
+ return "other";
8106
+ }
8107
+ function hasExternalDestination(sink) {
8108
+ return typeof sink.destination === "string" && sink.destination.length > 0;
8109
+ }
8110
+ var FLOW_RULES = [
8111
+ // CL-FLOW-001 — the canonical toxic flow: untrusted/sensitive data → external network
8112
+ // egress with a concrete outbound destination. The motivating incident (new9): a
8113
+ // secret / injected public content leaving to an attacker-controlled host.
8114
+ {
8115
+ id: "CL-FLOW-001",
8116
+ when: (s, k) => isUntrustedOrSensitive(s.trustSource) && sinkKind(k) === "network" && hasExternalDestination(k),
8117
+ decisionHint: "BLOCK",
8118
+ riskClass: "critical"
8119
+ },
8120
+ // CL-FLOW-002 — untrusted/sensitive data → financial action (spend). Money leaving is
8121
+ // irreversible; a tainted source driving it is a blocker regardless of destination.
8122
+ {
8123
+ id: "CL-FLOW-002",
8124
+ when: (s, k) => isUntrustedOrSensitive(s.trustSource) && sinkKind(k) === "financial",
8125
+ decisionHint: "BLOCK",
8126
+ riskClass: "critical"
8127
+ },
8128
+ // CL-FLOW-003 — untrusted/sensitive data → external network egress WITHOUT a pinned
8129
+ // destination (host unknown). Still dangerous, but the sink is less determined, so it
8130
+ // warrants human review rather than an outright block.
8131
+ {
8132
+ id: "CL-FLOW-003",
8133
+ when: (s, k) => isUntrustedOrSensitive(s.trustSource) && sinkKind(k) === "network" && !hasExternalDestination(k),
8134
+ decisionHint: "REVIEW",
8135
+ riskClass: "high"
8136
+ },
8137
+ // CL-FLOW-004 — untrusted/sensitive data → outbound messaging (email/chat). Exfiltration
8138
+ // via a messaging channel; review-worthy.
8139
+ {
8140
+ id: "CL-FLOW-004",
8141
+ when: (s, k) => isUntrustedOrSensitive(s.trustSource) && sinkKind(k) === "message",
8142
+ decisionHint: "REVIEW",
8143
+ riskClass: "high"
8144
+ },
8145
+ // CL-FLOW-ALLOW-001 — an established TRUSTED source reaching an egress sink is the
8146
+ // benign counterpart (ADR 0041 §2: trusted.user_explicit → send is routine). Only fires
8147
+ // when the source is positively trusted — never on unknown (I-04).
8148
+ {
8149
+ id: "CL-FLOW-ALLOW-001",
8150
+ when: (s, k) => isTrusted(s.trustSource) && sinkKind(k) !== "other",
8151
+ decisionHint: "ALLOW",
8152
+ riskClass: "none"
8153
+ },
8154
+ // CL-FLOW-REVIEW-000 — fail-safe catch-all. Any composition not positively classified
8155
+ // above (including one whose source trust could not be established) is REVIEW, never
8156
+ // ALLOW/SAFE. This keeps the classifier total and dangerous-flow-never-SAFE.
8157
+ {
8158
+ id: "CL-FLOW-REVIEW-000",
8159
+ when: () => true,
8160
+ decisionHint: "REVIEW",
8161
+ riskClass: "medium"
8162
+ }
8163
+ ];
8164
+ function classifyFlow(source, sink) {
8165
+ for (const rule2 of FLOW_RULES) {
8166
+ if (rule2.when(source, sink)) {
8167
+ return { ruleId: rule2.id, decisionHint: rule2.decisionHint, riskClass: rule2.riskClass };
8168
+ }
8169
+ }
8170
+ return { ruleId: "CL-FLOW-REVIEW-000", decisionHint: "REVIEW", riskClass: "medium" };
8171
+ }
8172
+
8173
+ // ../../packages/flow-analyzer/src/buildFlows.ts
8174
+ var CLASSIFIED_SOURCE_TRUST = /* @__PURE__ */ new Set([
8175
+ "sensitive.secret",
8176
+ "sensitive.private_data",
8177
+ "untrusted.public_content",
8178
+ "untrusted.tool_output",
8179
+ "untrusted.peer_agent",
8180
+ "untrusted.memory",
8181
+ "trusted.policy",
8182
+ "trusted.user_explicit",
8183
+ "trusted.local_project",
8184
+ "trusted.signed_component"
8185
+ ]);
8186
+ var EGRESS_SINKS = /* @__PURE__ */ new Set([
8187
+ "send:network",
8188
+ "send:message",
8189
+ "connect:network",
8190
+ "spend:financial"
8191
+ ]);
8192
+ function isSource(c) {
8193
+ return c.action === "read" && c.trustSource !== void 0 && CLASSIFIED_SOURCE_TRUST.has(c.trustSource);
8194
+ }
8195
+ function isSink(c) {
8196
+ return EGRESS_SINKS.has(`${c.action}:${c.resource}`);
8197
+ }
8198
+ function sourceFamily(ts) {
8199
+ if (ts.startsWith("sensitive.")) return "sensitive";
8200
+ if (ts.startsWith("trusted.")) return "trusted";
8201
+ return "untrusted";
8202
+ }
8203
+ var SEVERITY = {
8204
+ none: 0,
8205
+ low: 20,
8206
+ medium: 50,
8207
+ high: 75,
8208
+ critical: 95
8209
+ };
8210
+ function cmp3(a, b) {
8211
+ return a < b ? -1 : a > b ? 1 : 0;
8212
+ }
8213
+ function capKey(t) {
8214
+ const c = t.cap;
8215
+ return [
8216
+ t.manifestDigest,
8217
+ c.trustSource ?? "",
8218
+ c.action,
8219
+ c.resource,
8220
+ c.scope ?? "",
8221
+ c.destination ?? "",
8222
+ c.evidenceSource
8223
+ ].join("|");
8224
+ }
8225
+ function sealFlow(flow) {
8226
+ return { ...flow, digest: hashJson(flow) };
8227
+ }
8228
+ function buildFlows(manifests) {
8229
+ const tagged = manifests.flatMap((m) => m.capabilities.map((cap) => ({ cap, manifestDigest: m.digest }))).sort((a, b) => cmp3(capKey(a), capKey(b)));
8230
+ const sources = tagged.filter((t) => isSource(t.cap));
8231
+ const sinks = tagged.filter((t) => isSink(t.cap));
8232
+ const byDigest = /* @__PURE__ */ new Map();
8233
+ for (const src of sources) {
8234
+ for (const snk of sinks) {
8235
+ if (src.cap === snk.cap) continue;
8236
+ const ts = src.cap.trustSource;
8237
+ const outcome = classifyFlow(src.cap, snk.cap);
8238
+ const risk = {
8239
+ class: outcome.riskClass,
8240
+ severity: SEVERITY[outcome.riskClass]
8241
+ };
8242
+ const sinkTag = `${snk.cap.action}-${snk.cap.resource}`;
8243
+ const flowId = `flow:${sourceFamily(ts)}-to-${sinkTag}`;
8244
+ const evidence = [.../* @__PURE__ */ new Set([src.cap.evidenceSource, snk.cap.evidenceSource])].sort();
8245
+ const authorityDigests = [.../* @__PURE__ */ new Set([src.manifestDigest, snk.manifestDigest])].sort();
8246
+ const flow = sealFlow({
8247
+ schema: FLOW_SCHEMA_VERSION,
8248
+ flowId,
8249
+ source: { trustSource: ts, evidence: [src.cap.evidenceSource] },
8250
+ steps: [{ action: src.cap.action, resource: src.cap.resource, scope: src.cap.scope }],
8251
+ sink: {
8252
+ action: snk.cap.action,
8253
+ resource: snk.cap.resource,
8254
+ destination: snk.cap.destination
8255
+ },
8256
+ risk,
8257
+ decisionHint: outcome.decisionHint,
8258
+ evidence,
8259
+ authorityDigests
8260
+ });
8261
+ if (!byDigest.has(flow.digest)) byDigest.set(flow.digest, flow);
8262
+ }
8263
+ }
8264
+ return [...byDigest.values()].sort(
8265
+ (a, b) => cmp3(a.flowId, b.flowId) || cmp3(a.digest, b.digest)
8266
+ );
8267
+ }
8268
+
8269
+ // ../../packages/flow-analyzer/src/foldFlows.ts
8270
+ function hintToVerdict(hint) {
8271
+ switch (hint) {
8272
+ case "BLOCK":
8273
+ return "BLOCK";
8274
+ case "REVIEW":
8275
+ return "REVIEW";
8276
+ default:
8277
+ return null;
8278
+ }
8279
+ }
8280
+ function foldFlowsIntoReasons(flows) {
8281
+ const byKey = /* @__PURE__ */ new Map();
8282
+ for (const flow of flows) {
8283
+ const contributes = hintToVerdict(flow.decisionHint);
8284
+ if (contributes === null) continue;
8285
+ const firstEvidence = flow.evidence[0];
8286
+ const evidenceSource = firstEvidence ? `${flow.flowId} (${firstEvidence})` : flow.flowId;
8287
+ const reason = {
8288
+ code: "TOXIC_FLOW_COMPOSITION",
8289
+ evidenceSource,
8290
+ contributes
8291
+ };
8292
+ const prev = byKey.get(evidenceSource);
8293
+ if (!prev || severity(contributes) > severity(prev.contributes)) {
8294
+ byKey.set(evidenceSource, reason);
8295
+ }
8296
+ }
8297
+ return [...byKey.values()].sort(
8298
+ (a, b) => cmp4(a.evidenceSource, b.evidenceSource) || severity(b.contributes) - severity(a.contributes)
8299
+ );
8300
+ }
8301
+ function severity(v) {
8302
+ return v === "BLOCK" ? 3 : v === "UNKNOWN" ? 2 : v === "REVIEW" ? 1 : 0;
8303
+ }
8304
+ function cmp4(a, b) {
8305
+ return a < b ? -1 : a > b ? 1 : 0;
8306
+ }
8307
+
7921
8308
  // ../../packages/install-planner/src/buildPlan.ts
7922
8309
  function ptr(segment) {
7923
8310
  return segment.replace(/~/g, "~0").replace(/\//g, "~1");
@@ -8812,7 +9199,9 @@ function trustPrepare(args, deps) {
8812
9199
  const authority = buildAuthorityForTarget(input, artifact.digest ?? null);
8813
9200
  const policyPath = flagStr(args.flags, "policy");
8814
9201
  const policy = loadPolicyOrDefault(policyPath ? resolvePath4(deps.cwd, policyPath) : void 0);
8815
- const decision = artifact.resolution === "resolved" ? decideOverAuthority({ authority, evidence, policy }) : void 0;
9202
+ const flows = buildFlows([authority]);
9203
+ const flowReasons = foldFlowsIntoReasons(flows);
9204
+ const decision = artifact.resolution === "resolved" ? decideOverAuthority({ authority, evidence, policy, flowReasons }) : void 0;
8816
9205
  let plan;
8817
9206
  const hostFlag = flagStr(args.flags, "host");
8818
9207
  if (hostFlag && decision && decision.verdict !== "UNKNOWN") {
@@ -8839,10 +9228,18 @@ function trustPrepare(args, deps) {
8839
9228
  plan written: ${file}
8840
9229
  `;
8841
9230
  }
9231
+ const showFlows = flagBool(args.flags, "flows");
8842
9232
  if (flagBool(args.flags, "json")) {
8843
- return { stdout: JSON.stringify(preparation, null, 2), stderr: "", exitCode };
9233
+ const payload = showFlows ? { preparation, flows } : preparation;
9234
+ return { stdout: JSON.stringify(payload, null, 2), stderr: "", exitCode };
8844
9235
  }
8845
- return { stdout: renderPreparation(preparation) + planNote, stderr: "", exitCode };
9236
+ const flowNote = showFlows ? renderFlows(flows) : "";
9237
+ const conversion = renderConversionPrompt(preparation);
9238
+ return {
9239
+ stdout: renderPreparation(preparation) + flowNote + planNote + conversion,
9240
+ stderr: "",
9241
+ exitCode
9242
+ };
8846
9243
  }
8847
9244
  function loadPreparation(args, deps) {
8848
9245
  const file = args.positionals[1];
@@ -9137,6 +9534,46 @@ This is the READ-ONLY half of the Trust Gateway. It touched no live config
9137
9534
  `;
9138
9535
  return out;
9139
9536
  }
9537
+ function renderConversionPrompt(p) {
9538
+ const d = p.decision;
9539
+ if (!d || d.verdict === "BLOCK" || d.verdict === "UNKNOWN") return "";
9540
+ return `
9541
+ Next step (nothing is persisted unless you run one of these):
9542
+ \u2022 Record this surface as approved: calllint approve
9543
+ \u2022 Re-decide on every authority change: calllint guard install --host git
9544
+ \u2022 Gate pull requests in CI: calllint guard install --host github
9545
+ \u2022 Teach your agent the safety rule: calllint gen-rule --host claude --write
9546
+ `;
9547
+ }
9548
+ var FLOW_HINT_SYMBOL = {
9549
+ BLOCK: "\u26D4 BLOCK",
9550
+ REVIEW: "\u26A0 REVIEW",
9551
+ ALLOW: "\u{1F6E1} ALLOW"
9552
+ };
9553
+ function renderFlows(flows) {
9554
+ if (flows.length === 0) {
9555
+ return `
9556
+ toxic-flows: (none \u2014 no cross-capability composition detected)
9557
+ `;
9558
+ }
9559
+ let out = `
9560
+ toxic-flows: ${flows.length} path(s) [calllint.flow.v0]
9561
+ `;
9562
+ for (const f of flows) {
9563
+ const dst = f.sink.destination ? ` \u2192 ${f.sink.destination}` : "";
9564
+ out += ` ${FLOW_HINT_SYMBOL[f.decisionHint] ?? f.decisionHint} ${f.flowId} (${f.risk.class})
9565
+ `;
9566
+ out += ` source: ${f.source.trustSource} \u2192 sink: ${f.sink.action} ${f.sink.resource}${dst}
9567
+ `;
9568
+ out += ` evidence: ${f.evidence.join(", ")}
9569
+ `;
9570
+ }
9571
+ out += ` A dangerous flow is folded into the decision as a TOXIC_FLOW_COMPOSITION reason;
9572
+ `;
9573
+ out += ` it can only raise the verdict, never lower it. An ALLOW flow contributes nothing.
9574
+ `;
9575
+ return out;
9576
+ }
9140
9577
  function renderExplanation(p) {
9141
9578
  let out = `
9142
9579
  CallLint trust explain
@@ -9306,6 +9743,9 @@ OPTIONS (prepare)
9306
9743
  only for a non-blocking decision (SAFE/REVIEW).
9307
9744
  --host-config <path> Override the host config path (default: ~/.claude.json)
9308
9745
  --write-plan Persist the plan to .calllint/plans/<plan-id>.json
9746
+ --flows Show static toxic-flow paths (calllint.flow.v0) behind the
9747
+ decision's TOXIC_FLOW_COMPOSITION reasons. With --json, emits
9748
+ { preparation, flows }. A dangerous flow only raises the verdict.
9309
9749
  --no-llm Default posture: no LLM in the verdict path (accepted, no-op)
9310
9750
  --json Emit the raw calllint.trust-preparation.v0 document
9311
9751
 
@@ -9350,6 +9790,209 @@ SEE ALSO
9350
9790
  `;
9351
9791
  }
9352
9792
 
9793
+ // src/commands/guard.ts
9794
+ import { existsSync as existsSync12, readFileSync as readFileSync19, writeFileSync as writeFileSync11, mkdirSync as mkdirSync6 } from "node:fs";
9795
+ import { dirname as dirname6, join as join17, resolve as resolve10 } from "node:path";
9796
+ var GUARD_HOSTS = ["git", "github"];
9797
+ function guardConfigPath(cwd) {
9798
+ return join17(cwd, ".calllint", "guard.json");
9799
+ }
9800
+ function readGuardConfig(cwd) {
9801
+ const path = guardConfigPath(cwd);
9802
+ if (!existsSync12(path)) return void 0;
9803
+ try {
9804
+ return JSON.parse(readFileSync19(path, "utf8"));
9805
+ } catch {
9806
+ return void 0;
9807
+ }
9808
+ }
9809
+ function isDisabled(cwd, env) {
9810
+ if (env["CALLLINT_GUARD"] === "0") return true;
9811
+ return readGuardConfig(cwd)?.enabled === false;
9812
+ }
9813
+ function guardCommand(args, deps) {
9814
+ const sub = args.positionals[0];
9815
+ const env = deps.env ?? process.env;
9816
+ switch (sub) {
9817
+ case "install":
9818
+ return guardInstall(args, deps);
9819
+ case "status":
9820
+ return guardStatus(args, deps, env);
9821
+ case "disable":
9822
+ return guardSetEnabled(deps, false);
9823
+ case "enable":
9824
+ return guardSetEnabled(deps, true);
9825
+ case void 0:
9826
+ return guardRun(args, deps, env);
9827
+ default:
9828
+ return {
9829
+ stdout: "",
9830
+ stderr: `Unknown guard subcommand: ${sub}
9831
+ Run \`calllint guard\` (assess) or \`calllint guard install|status|disable|enable\`.`,
9832
+ exitCode: EXIT.USAGE
9833
+ };
9834
+ }
9835
+ }
9836
+ function exitForAction(a) {
9837
+ switch (a.action) {
9838
+ case "silent":
9839
+ case "note":
9840
+ return EXIT.OK;
9841
+ case "prompt":
9842
+ return EXIT.REVIEW;
9843
+ case "request-evidence":
9844
+ return EXIT.UNKNOWN;
9845
+ case "refuse":
9846
+ return EXIT.BLOCK;
9847
+ case "fail-closed":
9848
+ return EXIT.ERROR;
9849
+ }
9850
+ }
9851
+ function guardRun(args, deps, env) {
9852
+ const json = flagBool(args.flags, "json");
9853
+ if (isDisabled(deps.cwd, env)) {
9854
+ const note = "Continuous Guard is disabled (CALLLINT_GUARD=0 or .calllint/guard.json).";
9855
+ return {
9856
+ stdout: json ? JSON.stringify({ enabled: false, note }) : note,
9857
+ exitCode: EXIT.OK
9858
+ };
9859
+ }
9860
+ const approvedPath = flagStr(args.flags, "approved") ?? defaultApprovedPath(deps.cwd);
9861
+ const approved = readApproved(approvedPath);
9862
+ if (!approved) {
9863
+ return {
9864
+ stdout: "",
9865
+ stderr: `No approved baseline at ${approvedPath}. Run \`calllint approve\` first.`,
9866
+ exitCode: EXIT.USAGE
9867
+ };
9868
+ }
9869
+ let assessment;
9870
+ try {
9871
+ const current = decideRepoSurfaces(deps.cwd, { now: deps.now, generatedAt: deps.generatedAt });
9872
+ const drift = verifyApproved(current, approved, deps.generatedAt);
9873
+ assessment = assessGuardDrift(drift);
9874
+ } catch (err2) {
9875
+ assessment = guardFailClosed(err2 instanceof Error ? err2.message : String(err2));
9876
+ }
9877
+ const exitCode = exitForAction(assessment);
9878
+ if (json) {
9879
+ return { stdout: JSON.stringify(assessment), exitCode };
9880
+ }
9881
+ if (assessment.action === "silent") {
9882
+ return { stdout: "", exitCode: EXIT.OK };
9883
+ }
9884
+ const prefix = assessment.action === "fail-closed" ? "GUARD FAILED CLOSED" : assessment.action === "refuse" ? "BLOCK" : assessment.action === "request-evidence" ? "UNKNOWN" : assessment.action === "prompt" ? "REVIEW" : "NOTE";
9885
+ const failLine = assessment.failure ? `
9886
+ reason: ${assessment.failure}` : "";
9887
+ return { stdout: `${prefix}: ${assessment.summary}${failLine}`, exitCode };
9888
+ }
9889
+ function isGuardHost(v) {
9890
+ return v !== void 0 && GUARD_HOSTS.includes(v);
9891
+ }
9892
+ var GIT_HOOK = `#!/usr/bin/env bash
9893
+ # CallLint Continuous Guard (ADR 0045). Re-decides the agent-tool authority
9894
+ # surface on commit; silent when nothing changed. Generated by \`calllint guard install\`.
9895
+ # CallLint is static and NEVER executes a scanned server.
9896
+ npx -y calllint guard --no-emoji
9897
+ `;
9898
+ function guardArtifact(host) {
9899
+ if (host === "git") {
9900
+ return { path: join17(".git", "hooks", "pre-commit"), content: GIT_HOOK, label: "git pre-commit hook" };
9901
+ }
9902
+ return {
9903
+ path: join17(".github", "workflows", "calllint.yml"),
9904
+ content: renderCiGate({ mode: "drift" }),
9905
+ label: "GitHub Actions drift-gate workflow"
9906
+ };
9907
+ }
9908
+ function guardInstall(args, deps) {
9909
+ const host = flagStr(args.flags, "host");
9910
+ if (!host) {
9911
+ const list = GUARD_HOSTS.map((h2) => ` ${h2.padEnd(8)} \u2192 ${guardArtifact(h2).label}`).join("\n");
9912
+ return {
9913
+ stdout: `Usage: calllint guard install --host <host>
9914
+
9915
+ Hosts:
9916
+ ${list}`,
9917
+ exitCode: EXIT.OK
9918
+ };
9919
+ }
9920
+ if (!isGuardHost(host)) {
9921
+ return {
9922
+ stdout: "",
9923
+ stderr: `Unknown guard host: ${host}
9924
+ Run \`calllint guard install\` to list hosts.`,
9925
+ exitCode: EXIT.USAGE
9926
+ };
9927
+ }
9928
+ const art = guardArtifact(host);
9929
+ const rel = flagStr(args.flags, "out") ?? art.path;
9930
+ if (deps.writeFile === false) {
9931
+ return { stdout: `Would write ${rel} (${art.label})`, exitCode: EXIT.OK };
9932
+ }
9933
+ const abs = resolve10(deps.cwd, rel);
9934
+ try {
9935
+ mkdirSync6(dirname6(abs), { recursive: true });
9936
+ writeFileSync11(abs, art.content, "utf8");
9937
+ } catch (e) {
9938
+ return {
9939
+ stdout: "",
9940
+ stderr: `Failed to write ${rel}: ${e instanceof Error ? e.message : String(e)}`,
9941
+ exitCode: EXIT.ERROR
9942
+ };
9943
+ }
9944
+ const note = host === "git" ? `
9945
+ Make it executable: chmod +x ${rel}` : "";
9946
+ return { stdout: `Wrote ${rel} (${art.label})${note}`, exitCode: EXIT.OK };
9947
+ }
9948
+ function guardStatus(args, deps, env) {
9949
+ const approvedPath = flagStr(args.flags, "approved") ?? defaultApprovedPath(deps.cwd);
9950
+ const hasBaseline = existsSync12(approvedPath);
9951
+ const disabled = isDisabled(deps.cwd, env);
9952
+ const gitHook = existsSync12(join17(deps.cwd, ".git", "hooks", "pre-commit"));
9953
+ const ghWorkflow = existsSync12(join17(deps.cwd, ".github", "workflows", "calllint.yml"));
9954
+ const status = {
9955
+ enabled: !disabled,
9956
+ disabledBy: disabled ? env["CALLLINT_GUARD"] === "0" ? "env:CALLLINT_GUARD=0" : "flag:.calllint/guard.json" : null,
9957
+ approvedBaseline: hasBaseline ? approvedPath : null,
9958
+ installedHooks: {
9959
+ "git:pre-commit": gitHook,
9960
+ "github:workflow": ghWorkflow
9961
+ }
9962
+ };
9963
+ if (flagBool(args.flags, "json")) {
9964
+ return { stdout: JSON.stringify(status), exitCode: EXIT.OK };
9965
+ }
9966
+ const lines = [
9967
+ `Continuous Guard: ${status.enabled ? "enabled" : `disabled (${status.disabledBy})`}`,
9968
+ `Approved baseline: ${hasBaseline ? approvedPath : "none \u2014 run `calllint approve`"}`,
9969
+ `git pre-commit hook: ${gitHook ? "installed" : "not installed"}`,
9970
+ `GitHub workflow: ${ghWorkflow ? "installed" : "not installed"}`
9971
+ ];
9972
+ return { stdout: lines.join("\n"), exitCode: EXIT.OK };
9973
+ }
9974
+ function guardSetEnabled(deps, enabled) {
9975
+ const cfg = { schemaVersion: "calllint.guard-config.v0", enabled };
9976
+ if (deps.writeFile === false) {
9977
+ return { stdout: enabled ? "Would enable Continuous Guard" : "Would disable Continuous Guard", exitCode: EXIT.OK };
9978
+ }
9979
+ const path = guardConfigPath(deps.cwd);
9980
+ try {
9981
+ mkdirSync6(dirname6(path), { recursive: true });
9982
+ writeFileSync11(path, JSON.stringify(cfg, null, 2), "utf8");
9983
+ } catch (e) {
9984
+ return {
9985
+ stdout: "",
9986
+ stderr: `Failed to write ${path}: ${e instanceof Error ? e.message : String(e)}`,
9987
+ exitCode: EXIT.ERROR
9988
+ };
9989
+ }
9990
+ return {
9991
+ stdout: enabled ? "Continuous Guard enabled (.calllint/guard.json)." : "Continuous Guard disabled (.calllint/guard.json). Re-enable with `calllint guard enable`.",
9992
+ exitCode: EXIT.OK
9993
+ };
9994
+ }
9995
+
9353
9996
  // src/run.ts
9354
9997
  function run(argv, deps) {
9355
9998
  const args = parseArgs(argv);
@@ -9428,6 +10071,13 @@ function run(argv, deps) {
9428
10071
  return evidenceCommand(args, { cwd: deps.cwd });
9429
10072
  case "trust":
9430
10073
  return trustCommand(args, { cwd: deps.cwd, generatedAt: deps.generatedAt, toolVersion: deps.toolVersion });
10074
+ case "guard":
10075
+ return guardCommand(args, {
10076
+ cwd: deps.cwd,
10077
+ now: deps.now,
10078
+ generatedAt: deps.generatedAt,
10079
+ writeFile: deps.writeCacheFile
10080
+ });
9431
10081
  case "gen-rule":
9432
10082
  return genRuleCommand(args, { cwd: deps.cwd });
9433
10083
  case "policy":
@@ -9701,13 +10351,13 @@ async function breathe(argv, deps = {}) {
9701
10351
  }
9702
10352
 
9703
10353
  // src/version.ts
9704
- import { readFileSync as readFileSync19 } from "node:fs";
10354
+ import { readFileSync as readFileSync20 } from "node:fs";
9705
10355
  import { fileURLToPath } from "node:url";
9706
- import { dirname as dirname6, join as join17 } from "node:path";
10356
+ import { dirname as dirname7, join as join18 } from "node:path";
9707
10357
  function resolveToolVersion() {
9708
10358
  try {
9709
- const pkgPath = join17(dirname6(fileURLToPath(import.meta.url)), "..", "package.json");
9710
- const pkg = JSON.parse(readFileSync19(pkgPath, "utf8"));
10359
+ const pkgPath = join18(dirname7(fileURLToPath(import.meta.url)), "..", "package.json");
10360
+ const pkg = JSON.parse(readFileSync20(pkgPath, "utf8"));
9711
10361
  return typeof pkg.version === "string" && pkg.version.length > 0 ? pkg.version : "unknown";
9712
10362
  } catch {
9713
10363
  return "unknown";
@@ -9717,7 +10367,7 @@ function resolveToolVersion() {
9717
10367
  // src/index.ts
9718
10368
  function readStdin() {
9719
10369
  try {
9720
- return readFileSync20(0, "utf8");
10370
+ return readFileSync21(0, "utf8");
9721
10371
  } catch {
9722
10372
  return "";
9723
10373
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "calllint",
3
- "version": "1.4.0",
3
+ "version": "1.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",
@@ -55,6 +55,7 @@
55
55
  "@calllint/evidence": "workspace:*",
56
56
  "@calllint/fingerprint": "workspace:*",
57
57
  "@calllint/fixtures": "workspace:*",
58
+ "@calllint/flow-analyzer": "workspace:*",
58
59
  "@calllint/install-planner": "workspace:*",
59
60
  "@calllint/online": "workspace:*",
60
61
  "@calllint/policy": "workspace:*",