@cirvix_ai/agent-control 0.1.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 (45) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +42 -0
  3. package/README.md +341 -0
  4. package/action/README.md +100 -0
  5. package/action/action.yml +134 -0
  6. package/action/report.mjs +144 -0
  7. package/bin/cirvix.mjs +1073 -0
  8. package/package.json +60 -0
  9. package/src/commands/demo.mjs +315 -0
  10. package/src/commands/init.mjs +558 -0
  11. package/src/commands/policy.mjs +345 -0
  12. package/src/commands/sarif.mjs +176 -0
  13. package/src/commands/scan.mjs +210 -0
  14. package/src/commands/status.mjs +208 -0
  15. package/src/commands/upgrade.mjs +162 -0
  16. package/src/core/approvals.mjs +388 -0
  17. package/src/core/audit.mjs +181 -0
  18. package/src/core/canonical.mjs +316 -0
  19. package/src/core/daemon.mjs +352 -0
  20. package/src/core/decisions.mjs +253 -0
  21. package/src/core/delegation.mjs +658 -0
  22. package/src/core/detect.mjs +337 -0
  23. package/src/core/entitlement-gate.mjs +100 -0
  24. package/src/core/entitlements.mjs +285 -0
  25. package/src/core/format.mjs +33 -0
  26. package/src/core/gateway.mjs +959 -0
  27. package/src/core/guard.mjs +568 -0
  28. package/src/core/http-transport.mjs +505 -0
  29. package/src/core/journal.mjs +419 -0
  30. package/src/core/jsonrpc.mjs +152 -0
  31. package/src/core/meter.mjs +225 -0
  32. package/src/core/normalize.mjs +516 -0
  33. package/src/core/notices.mjs +80 -0
  34. package/src/core/pipeline.mjs +629 -0
  35. package/src/core/policy-dsl.mjs +611 -0
  36. package/src/core/policy.mjs +710 -0
  37. package/src/core/prompts.mjs +146 -0
  38. package/src/core/risk.mjs +509 -0
  39. package/src/core/sanitize.mjs +279 -0
  40. package/src/core/secret-detect.mjs +533 -0
  41. package/src/core/secrets.mjs +312 -0
  42. package/src/core/uds.mjs +383 -0
  43. package/src/core/vault.mjs +530 -0
  44. package/src/index.mjs +143 -0
  45. package/src/testing.mjs +145 -0
@@ -0,0 +1,345 @@
1
+ /**
2
+ * `cirvix policy` — check, test, explain, list.
3
+ *
4
+ * cirvix policy check does this file load, and is it sane
5
+ * cirvix policy test run the test cases the file declares
6
+ * cirvix policy explain --tool … why would this call be decided that way
7
+ * cirvix policy list the active rules
8
+ *
9
+ * WHY `explain` IS A FIRST-CLASS COMMAND
10
+ *
11
+ * The single most common failure of a policy engine in production is not a
12
+ * wrong decision — it is a rule that everyone believes is protecting them and
13
+ * which has never matched anything. It loads, it validates, it appears in the
14
+ * list, and it is dead. `explain` prints every rule that was considered and
15
+ * whether it matched, so a dead rule is visible the first time somebody looks
16
+ * rather than during the incident it failed to prevent.
17
+ *
18
+ * WHY `test` MATTERS MORE THAN `check`
19
+ *
20
+ * `check` proves the file parses. `test` proves it does what the author meant.
21
+ * Only the second one survives a refactor, and a rule set nobody dares change
22
+ * ossifies until it is bypassed rather than updated.
23
+ */
24
+
25
+ import { readFile } from "node:fs/promises";
26
+
27
+ import { evaluate, parseRules, validateRules } from "../core/policy.mjs";
28
+ import { compile, toSource, PolicySyntaxError } from "../core/policy-dsl.mjs";
29
+ import { DECISION, toDecision } from "../core/decisions.mjs";
30
+ import { normalize, policyRequest } from "../core/normalize.mjs";
31
+ import { classify } from "../core/risk.mjs";
32
+ import { bold, dim, green, red, amber, blue, plural } from "../core/format.mjs";
33
+
34
+ /* -------------------------------------------------------------------------- */
35
+ /* Loading */
36
+ /* -------------------------------------------------------------------------- */
37
+
38
+ /**
39
+ * Loads a policy from either format.
40
+ *
41
+ * `.json` is the engine's on-disk shape; anything else is the DSL. Sniffing the
42
+ * content rather than trusting the extension, because a `.policy` file
43
+ * containing JSON should still work — the alternative is a confusing parse
44
+ * error about an unexpected `{`.
45
+ */
46
+ export async function loadPolicyFile(path, { cwd = process.cwd() } = {}) {
47
+ const source = await readFile(path, "utf8");
48
+ const looksJson = /^\s*[[{]/.test(source);
49
+
50
+ if (looksJson) {
51
+ const rules = parseRules(JSON.parse(source));
52
+ return { rules, tests: [], format: "json", source, path };
53
+ }
54
+
55
+ const { rules, tests } = compile(source, { cwd, origin: path });
56
+ return { rules, tests, format: "dsl", source, path };
57
+ }
58
+
59
+ /* -------------------------------------------------------------------------- */
60
+ /* check */
61
+ /* -------------------------------------------------------------------------- */
62
+
63
+ /**
64
+ * Parses and validates. Exit code 1 on an error, 0 on warnings only — warnings
65
+ * are things that load and probably should not, and failing CI on them would
66
+ * teach people to stop reading them.
67
+ */
68
+ export async function check({ path, cwd = process.cwd(), json = false, strict = false }) {
69
+ let loaded;
70
+ try {
71
+ loaded = await loadPolicyFile(path, { cwd });
72
+ } catch (err) {
73
+ const result = {
74
+ ok: false,
75
+ path,
76
+ errors: [{ rule: null, message: err.message }],
77
+ warnings: [],
78
+ rules: 0,
79
+ };
80
+ return {
81
+ result,
82
+ code: 1,
83
+ output: json
84
+ ? JSON.stringify(result, null, 2)
85
+ : `\n ${red(bold("policy did not load"))}\n\n ${err instanceof PolicySyntaxError ? err.message.split("\n").join("\n ") : err.message}\n\n`,
86
+ };
87
+ }
88
+
89
+ const validation = validateRules(loaded.rules);
90
+ const result = {
91
+ ok: validation.ok,
92
+ path,
93
+ format: loaded.format,
94
+ rules: validation.rules,
95
+ tests: loaded.tests.length,
96
+ errors: validation.errors,
97
+ warnings: validation.warnings,
98
+ };
99
+
100
+ const code = validation.ok ? (strict && validation.warnings.length ? 1 : 0) : 1;
101
+
102
+ if (json) return { result, code, output: JSON.stringify(result, null, 2) };
103
+
104
+ const lines = [""];
105
+ if (validation.ok) {
106
+ lines.push(
107
+ ` ${green(bold("policy is valid"))} ${dim(`${plural(validation.rules, "rule")}, ${plural(loaded.tests.length, "test case")} · ${loaded.format}`)}`,
108
+ );
109
+ } else {
110
+ lines.push(` ${red(bold("policy is invalid"))} ${dim(`${plural(validation.errors.length, "error")}`)}`);
111
+ }
112
+ lines.push("");
113
+
114
+ for (const e of validation.errors) {
115
+ lines.push(` ${red("error")} ${e.rule ? bold(e.rule) + " " : ""}${e.message}`);
116
+ }
117
+ for (const w of validation.warnings) {
118
+ lines.push(` ${amber("warning")} ${w.rule ? bold(w.rule) + " " : ""}${dim(w.message)}`);
119
+ }
120
+ if (validation.errors.length || validation.warnings.length) lines.push("");
121
+ if (validation.ok && loaded.tests.length) {
122
+ lines.push(` ${dim("Run the test cases:")} ${blue("cirvix policy test")}`);
123
+ lines.push("");
124
+ }
125
+
126
+ return { result, code, output: lines.join("\n") };
127
+ }
128
+
129
+ /* -------------------------------------------------------------------------- */
130
+ /* test */
131
+ /* -------------------------------------------------------------------------- */
132
+
133
+ /**
134
+ * Runs the `test` blocks declared in the policy file.
135
+ *
136
+ * Every case is evaluated through the same normalization the runtime uses, so a
137
+ * test passing means the *runtime* would decide that way — not that the rule
138
+ * matcher would, given a hand-built request the runtime never constructs.
139
+ */
140
+ export async function test({ path, cwd = process.cwd(), json = false, filter = null }) {
141
+ const loaded = await loadPolicyFile(path, { cwd });
142
+
143
+ if (!loaded.tests.length) {
144
+ const result = { ok: true, total: 0, passed: 0, failed: 0, cases: [] };
145
+ return {
146
+ result,
147
+ code: 0,
148
+ output: json
149
+ ? JSON.stringify(result, null, 2)
150
+ : `\n ${amber("no test cases")} ${dim(`${path} declares no \`test\` blocks.`)}\n\n ${dim("A policy file that ships its own tests is one you can change safely. Add:")}\n\n${dim(' test "dotenv is not readable":\n tool = filesystem.read\n path = .env\n expect deny')}\n\n`,
151
+ };
152
+ }
153
+
154
+ const cases = [];
155
+ for (const t of loaded.tests) {
156
+ if (filter && !t.name.toLowerCase().includes(String(filter).toLowerCase())) continue;
157
+
158
+ const call = normalize(
159
+ { tool: t.call.tool, server: t.call.server ?? null, arguments: t.call.arguments },
160
+ { agent: t.call.agent, environment: t.call.environment, cwd },
161
+ );
162
+ const decision = evaluate(policyRequest(call), loaded.rules, { cwd });
163
+ const actual = decision.decision ?? toDecision(decision.verdict);
164
+ const expected = normalizeExpectation(t.expect);
165
+
166
+ cases.push({
167
+ name: t.name,
168
+ line: t.line,
169
+ expected,
170
+ actual,
171
+ passed: actual === expected,
172
+ rule: decision.rule,
173
+ risk: call.risk,
174
+ tool: call.tool,
175
+ resource: call.resource,
176
+ reason: decision.reason,
177
+ });
178
+ }
179
+
180
+ const passed = cases.filter((c) => c.passed).length;
181
+ const failed = cases.length - passed;
182
+ const result = { ok: failed === 0, total: cases.length, passed, failed, cases };
183
+
184
+ if (json) return { result, code: failed ? 1 : 0, output: JSON.stringify(result, null, 2) };
185
+
186
+ const lines = ["", ` ${bold(path)}`, ""];
187
+ for (const c of cases) {
188
+ if (c.passed) {
189
+ lines.push(` ${green("✓")} ${c.name} ${dim(`→ ${c.actual}${c.rule ? ` (${c.rule})` : ""}`)}`);
190
+ } else {
191
+ lines.push(` ${red("✗")} ${bold(c.name)} ${dim(`line ${c.line}`)}`);
192
+ lines.push(` ${dim("expected")} ${green(c.expected)}`);
193
+ lines.push(` ${dim("actual")} ${red(c.actual)}${c.rule ? dim(` by ${c.rule}`) : dim(" by default-deny")}`);
194
+ lines.push(` ${dim("call")} ${c.tool} ${dim(c.resource || "")} ${dim(`risk ${String(c.risk).toUpperCase()}`)}`);
195
+ lines.push(` ${dim(c.reason ?? "")}`);
196
+ lines.push("");
197
+ }
198
+ }
199
+ lines.push("");
200
+ lines.push(
201
+ failed === 0
202
+ ? ` ${green(bold(`${passed} passed`))}`
203
+ : ` ${red(bold(`${failed} failed`))} ${dim(`${passed} passed`)}`,
204
+ );
205
+ lines.push("");
206
+
207
+ return { result, code: failed ? 1 : 0, output: lines.join("\n") };
208
+ }
209
+
210
+ /** `allow`/`permit` are the same expectation; so are `deny`/`forbid`. */
211
+ function normalizeExpectation(value) {
212
+ const v = String(value).toLowerCase();
213
+ return (
214
+ {
215
+ allow: DECISION.ALLOW,
216
+ permit: DECISION.ALLOW,
217
+ deny: DECISION.DENY,
218
+ forbid: DECISION.DENY,
219
+ denied: DECISION.DENY,
220
+ hold: DECISION.REQUIRE_APPROVAL,
221
+ require_approval: DECISION.REQUIRE_APPROVAL,
222
+ approval: DECISION.REQUIRE_APPROVAL,
223
+ sanitize: DECISION.SANITIZE,
224
+ audit_only: DECISION.AUDIT_ONLY,
225
+ audit: DECISION.AUDIT_ONLY,
226
+ }[v] ?? v
227
+ );
228
+ }
229
+
230
+ /* -------------------------------------------------------------------------- */
231
+ /* explain */
232
+ /* -------------------------------------------------------------------------- */
233
+
234
+ /**
235
+ * Why would this call be decided this way?
236
+ *
237
+ * Prints the normalized call, the risk classification with the signals that
238
+ * fired, the decision, and every rule that was considered with whether it
239
+ * matched. The considered-list is the part that finds dead rules.
240
+ */
241
+ export async function explain({
242
+ path,
243
+ rules = null,
244
+ cwd = process.cwd(),
245
+ json = false,
246
+ tool,
247
+ args = {},
248
+ agent = "local",
249
+ environment = "local",
250
+ }) {
251
+ // `rules` lets this run against the built-in starter set, which has no file.
252
+ const loaded = rules ? { rules } : await loadPolicyFile(path, { cwd });
253
+
254
+ const call = normalize({ tool, arguments: args }, { agent, environment, cwd });
255
+ const risk = classify(call);
256
+ call.risk = risk.level;
257
+
258
+ const decision = evaluate(policyRequest(call), loaded.rules, { cwd });
259
+ const final = decision.decision ?? toDecision(decision.verdict);
260
+
261
+ const result = {
262
+ call: {
263
+ tool: call.tool,
264
+ action: call.action,
265
+ resource: call.resource,
266
+ destination: call.destination,
267
+ command: call.command,
268
+ agent: call.agent,
269
+ environment: call.environment,
270
+ insideWorkspace: call.insideWorkspace,
271
+ egress: call.egress,
272
+ },
273
+ risk: { level: risk.level, signals: risk.signals, reason: risk.reason, posture: risk.posture },
274
+ decision: final,
275
+ rule: decision.rule,
276
+ reason: decision.reason,
277
+ remediation: decision.remediation ?? null,
278
+ approvers: decision.approvers ?? [],
279
+ considered: decision.considered,
280
+ };
281
+
282
+ if (json) return { result, code: final === DECISION.DENY ? 1 : 0, output: JSON.stringify(result, null, 2) };
283
+
284
+ const tone = { allow: green, sanitize: blue, require_approval: amber, deny: red, audit_only: dim }[final] ?? dim;
285
+ const riskTone = { low: dim, medium: blue, high: amber, critical: red }[risk.level] ?? dim;
286
+
287
+ const lines = [
288
+ "",
289
+ ` ${tone(bold(String(final).toUpperCase().replace(/_/g, " ")))} ${bold(call.tool)} ${dim(call.resource || call.command || "")}`,
290
+ "",
291
+ ` ${dim("action")} ${call.action}`,
292
+ ` ${dim("resource")} ${call.resource || dim("—")}`,
293
+ call.command ? ` ${dim("command")} ${call.command}` : "",
294
+ call.destination ? ` ${dim("destination")} ${call.destination}` : "",
295
+ ` ${dim("workspace")} ${call.insideWorkspace ? "inside" : red("outside")}`,
296
+ ` ${dim("egress")} ${call.egress}`,
297
+ "",
298
+ ` ${dim("risk")} ${riskTone(bold(risk.level.toUpperCase()))} ${dim(`default posture: ${risk.posture}`)}`,
299
+ ...risk.signals.map((s) => ` ${dim("·")} ${s.id.padEnd(28)} ${dim(s.why)}`),
300
+ "",
301
+ ` ${dim("rule")} ${decision.rule ?? dim("— no rule matched (default deny)")}`,
302
+ ` ${dim("reason")} ${decision.reason}`,
303
+ decision.remediation ? ` ${dim("fix")} ${blue(decision.remediation)}` : "",
304
+ decision.approvers?.length ? ` ${dim("waits on")} ${decision.approvers.join(", ")}` : "",
305
+ "",
306
+ ` ${dim("considered")} ${dim(`${decision.considered.filter((c) => c.matched).length} of ${decision.considered.length} matched`)}`,
307
+ ...decision.considered.map(
308
+ (c) =>
309
+ ` ${c.matched ? bold("→") : dim(" ")} ${dim(String(c.effect).padEnd(11))} ${c.matched ? c.rule : dim(c.rule)}`,
310
+ ),
311
+ "",
312
+ ].filter((l) => l !== "");
313
+
314
+ return { result, code: final === DECISION.DENY ? 1 : 0, output: lines.join("\n") + "\n" };
315
+ }
316
+
317
+ /* -------------------------------------------------------------------------- */
318
+ /* list */
319
+ /* -------------------------------------------------------------------------- */
320
+
321
+ export function list(rules, { json = false, source = false, cwd = process.cwd() } = {}) {
322
+ if (json) return { output: JSON.stringify(rules, null, 2), code: 0 };
323
+ if (source) return { output: "\n" + toSource(rules, { cwd }) + "\n", code: 0 };
324
+
325
+ const tone = { permit: green, forbid: red, hold: amber, sanitize: blue, audit_only: dim };
326
+ const label = { permit: "allow", forbid: "deny", hold: "approval", sanitize: "sanitize", audit_only: "audit" };
327
+
328
+ const lines = ["", ` ${bold(plural(rules.length, "rule"))}`, ""];
329
+ for (const r of rules) {
330
+ const paint = tone[r.effect] ?? dim;
331
+ lines.push(` ${paint((label[r.effect] ?? r.effect).padEnd(9))} ${bold(r.name)}`);
332
+ const scope = [
333
+ r.actions?.length ? r.actions.join(", ") : null,
334
+ r.resources?.length ? r.resources.join(", ") : null,
335
+ r.when?.length ? r.when.map((c) => `${c.path} ${c.op} ${Array.isArray(c.value) ? `[${c.value.join("|")}]` : c.value}`).join(" and ") : null,
336
+ ]
337
+ .filter(Boolean)
338
+ .join(" · ");
339
+ if (scope) lines.push(` ${dim(scope)}`);
340
+ if (r.reason) lines.push(` ${dim(r.reason)}`);
341
+ lines.push("");
342
+ }
343
+
344
+ return { output: lines.join("\n"), code: 0 };
345
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * SARIF 2.1.0 output for `cirvix scan`.
3
+ *
4
+ * The point is not the file format. It is that a finding uploaded as SARIF
5
+ * lands in GitHub's Security tab and on the pull request diff, where somebody
6
+ * will see it — rather than in a CI log, where a red X means "the build broke"
7
+ * and gets retried. A security tool whose findings only appear in logs is a
8
+ * security tool people learn to ignore.
9
+ *
10
+ * TWO THINGS THAT MAKE THE UPLOAD BEHAVE
11
+ *
12
+ * 1. `partialFingerprints` is stable across runs. Without it, GitHub treats
13
+ * every scan as a fresh set of findings, so an issue somebody triaged and
14
+ * dismissed reappears on the next push and the whole feature becomes noise.
15
+ * The fingerprint is over the rule and the subject, not over the message,
16
+ * so rewording a `detail` string does not resurrect a dismissal.
17
+ *
18
+ * 2. Paths are repository-relative. SARIF's `uri` is resolved against the
19
+ * checkout root, and an absolute path from the scanning machine points at
20
+ * nothing on GitHub's side — the finding uploads successfully and then
21
+ * appears attached to no file at all.
22
+ */
23
+
24
+ import { createHash } from "node:crypto";
25
+ import { relative } from "node:path";
26
+
27
+ const VERSION = "0.1.0";
28
+
29
+ /** SARIF has three levels; ours has three severities. They do not line up 1:1. */
30
+ const LEVEL = { high: "error", medium: "warning", low: "note" };
31
+
32
+ /**
33
+ * GitHub sorts and filters on `security-severity`, a CVSS-like number, and
34
+ * shows nothing useful without it.
35
+ */
36
+ const SECURITY_SEVERITY = { high: "8.0", medium: "5.0", low: "3.0" };
37
+
38
+ const HELP = {
39
+ "runtime-ungoverned":
40
+ "This agent runtime routes tool calls directly to MCP servers. Point it at `cirvix gateway` so every call is evaluated against policy first.",
41
+ "framework-unguarded":
42
+ "This framework's tool boundary has no guard on it. Wrap the executor with `guard.wrap()` from `@cirvix_ai/agent-control`.",
43
+ "server-broad-scope":
44
+ "This MCP server is configured with a scope far wider than a workspace. Narrow it, or add a policy rule bounding what an agent may reach through it.",
45
+ "server-inline-secrets":
46
+ "Credentials sit in a plaintext MCP configuration file. Move them behind a secret handle so the agent never holds the value.",
47
+ "server-duplicated":
48
+ "The same server is configured independently in several runtimes, so a change in one leaves the others behind.",
49
+ "env-readable":
50
+ "A .env file is readable by any agent with filesystem access. This is the most common path from a prompt injection to a live credential.",
51
+ "credential-readable":
52
+ "Cloud, SSH, or registry credentials are readable from agent context. Deny reads of this path and broker the value instead.",
53
+ };
54
+
55
+ /**
56
+ * Converts a scan result to a SARIF log.
57
+ *
58
+ * @param {object} result the object `scan()` returns
59
+ * @param {object} [opts]
60
+ * @param {string} [opts.root] repository root, for relative paths
61
+ */
62
+ export function toSarif(result, { root = process.cwd() } = {}) {
63
+ const findings = result.findings ?? [];
64
+
65
+ // One rule per code, not per finding — SARIF's model is "rules produce
66
+ // results", and emitting a rule per result makes GitHub's rule filter
67
+ // useless.
68
+ const codes = [...new Set(findings.map((f) => f.code))];
69
+ const rules = codes.map((code) => ({
70
+ id: code,
71
+ name: code.replace(/(^|-)(\w)/g, (_, dash, c) => (dash ? "" : "") + c.toUpperCase()),
72
+ shortDescription: { text: describe(code) },
73
+ fullDescription: { text: HELP[code] ?? describe(code) },
74
+ help: { text: HELP[code] ?? describe(code) },
75
+ defaultConfiguration: {
76
+ level: LEVEL[severityOf(findings, code)] ?? "warning",
77
+ },
78
+ properties: {
79
+ tags: ["security", "ai-agents", "cirvix"],
80
+ "security-severity": SECURITY_SEVERITY[severityOf(findings, code)] ?? "5.0",
81
+ },
82
+ }));
83
+
84
+ return {
85
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
86
+ version: "2.1.0",
87
+ runs: [
88
+ {
89
+ tool: {
90
+ driver: {
91
+ name: "Cirvix AgentControl",
92
+ informationUri: "https://www.cirvix.com",
93
+ version: VERSION,
94
+ semanticVersion: VERSION,
95
+ rules,
96
+ },
97
+ },
98
+ // A scan of a developer machine finds things outside the repository —
99
+ // an SSH key, a globally configured MCP server. Those are real
100
+ // findings and they are reported, but they cannot be attached to a
101
+ // line of code, so they are anchored at the repository root rather
102
+ // than at a path GitHub cannot resolve.
103
+ results: findings.map((finding) => ({
104
+ ruleId: finding.code,
105
+ level: LEVEL[finding.severity] ?? "warning",
106
+ message: { text: `${finding.subject}: ${finding.detail}` },
107
+ locations: [
108
+ {
109
+ physicalLocation: {
110
+ artifactLocation: { uri: locationFor(finding, root) },
111
+ region: { startLine: 1 },
112
+ },
113
+ },
114
+ ],
115
+ partialFingerprints: {
116
+ cirvixFindingV1: fingerprint(finding),
117
+ },
118
+ properties: {
119
+ severity: finding.severity,
120
+ remediation: finding.fix ?? null,
121
+ },
122
+ })),
123
+ invocations: [
124
+ {
125
+ executionSuccessful: true,
126
+ endTimeUtc: result.scannedAt,
127
+ },
128
+ ],
129
+ },
130
+ ],
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Stable across runs, so a dismissal sticks.
136
+ *
137
+ * Over the rule and the subject only — rewording a `detail` string must not
138
+ * resurrect something a human already triaged.
139
+ */
140
+ function fingerprint(finding) {
141
+ return createHash("sha256")
142
+ .update(`${finding.code}\u0000${finding.subject}`)
143
+ .digest("hex")
144
+ .slice(0, 32);
145
+ }
146
+
147
+ function locationFor(finding, root) {
148
+ const raw = finding.path ?? finding.file ?? null;
149
+ if (!raw) return ".";
150
+ const rel = relative(root, raw).split("\\").join("/");
151
+ // A path outside the checkout resolves to nothing on GitHub's side, and a
152
+ // result attached to nothing is worse than one attached to the root.
153
+ return rel && !rel.startsWith("..") ? rel : ".";
154
+ }
155
+
156
+ function severityOf(findings, code) {
157
+ const order = { high: 0, medium: 1, low: 2 };
158
+ return findings
159
+ .filter((f) => f.code === code)
160
+ .map((f) => f.severity)
161
+ .sort((a, b) => order[a] - order[b])[0];
162
+ }
163
+
164
+ function describe(code) {
165
+ return (
166
+ {
167
+ "runtime-ungoverned": "An agent runtime is not routed through a control plane",
168
+ "framework-unguarded": "An agent framework has no guard on its tool boundary",
169
+ "server-broad-scope": "An MCP server is configured with a scope wider than a workspace",
170
+ "server-inline-secrets": "An MCP configuration carries credentials inline",
171
+ "server-duplicated": "One MCP server is configured independently in several runtimes",
172
+ "env-readable": "A .env file is readable from agent context",
173
+ "credential-readable": "Cloud or SSH credentials are readable from agent context",
174
+ }[code] ?? code
175
+ );
176
+ }