runcap 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/policy.mjs ADDED
@@ -0,0 +1,208 @@
1
+ // Policy-bound missions (runcap mission / policy / ci).
2
+ //
3
+ // A mission's rules are declared once in `.runcap/mission.yaml` (or .yml/.json),
4
+ // enforced during the run by the existing gateway cap + verification guard, and
5
+ // graded into a PASS/BLOCKED verdict a GitHub Action turns into a red/green PR
6
+ // check. This module is deliberately pure: it parses the policy, validates it,
7
+ // and grades an ALREADY-BUILT outcome receipt against it. It imports only
8
+ // js-yaml + node builtins and never imports mission-control, so there is no
9
+ // import cycle (mission-control imports FROM here, one direction).
10
+
11
+ import { createHash } from "node:crypto";
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import path from "node:path";
14
+ import yaml from "js-yaml";
15
+
16
+ const POLICY_FILENAMES = ["mission.yaml", "mission.yml", "mission.json"];
17
+
18
+ // Find and parse the policy. Precedence: an explicit path, else the first of
19
+ // .runcap/mission.{yaml,yml,json} that exists. Returns null when none is found
20
+ // so callers can decide whether a missing policy is an error.
21
+ export function loadPolicy(cwd = process.cwd(), explicitPath) {
22
+ let source = null;
23
+ if (explicitPath) {
24
+ source = path.isAbsolute(explicitPath) ? explicitPath : path.join(cwd, explicitPath);
25
+ if (!existsSync(source)) throw new Error(`Policy file not found: ${explicitPath}`);
26
+ } else {
27
+ for (const name of POLICY_FILENAMES) {
28
+ const candidate = path.join(cwd, ".runcap", name);
29
+ if (existsSync(candidate)) { source = candidate; break; }
30
+ }
31
+ }
32
+ if (!source) return null;
33
+
34
+ const raw = readFileSync(source, "utf8");
35
+ let policy;
36
+ if (source.endsWith(".json")) {
37
+ // .json fallback uses native JSON.parse so the zero-config path needs no parser.
38
+ policy = JSON.parse(raw);
39
+ } else {
40
+ policy = yaml.load(raw);
41
+ }
42
+ if (!policy || typeof policy !== "object") {
43
+ throw new Error(`Policy file ${path.basename(source)} did not parse to an object.`);
44
+ }
45
+ return {
46
+ policy,
47
+ raw,
48
+ hash: createHash("sha256").update(raw).digest("hex"),
49
+ source
50
+ };
51
+ }
52
+
53
+ // Validate the policy shape. Errors block the mission; warnings are advisory.
54
+ export function validatePolicy(policy) {
55
+ const errors = [];
56
+ const warnings = [];
57
+ if (!policy || typeof policy !== "object") {
58
+ return { ok: false, errors: ["Policy is empty or not an object."], warnings };
59
+ }
60
+
61
+ if (policy.version !== "v1") {
62
+ errors.push(`version must be "v1" (got ${JSON.stringify(policy.version)}).`);
63
+ }
64
+
65
+ const mission = policy.mission ?? {};
66
+ if (!mission.name || !String(mission.name).trim()) {
67
+ errors.push("mission.name is required.");
68
+ }
69
+
70
+ const verification = policy.verification ?? {};
71
+ if (!verification.command || !String(verification.command).trim()) {
72
+ errors.push("verification.command is required.");
73
+ }
74
+ if (verification.guard && !["strict", "off"].includes(verification.guard)) {
75
+ errors.push(`verification.guard must be "strict" or "off" (got ${JSON.stringify(verification.guard)}).`);
76
+ }
77
+
78
+ const budget = policy.budget ?? {};
79
+ const limit = budget.mission_hard_limit_usd;
80
+ if (!(typeof limit === "number" && Number.isFinite(limit) && limit > 0)) {
81
+ errors.push("budget.mission_hard_limit_usd is required and must be a positive number.");
82
+ }
83
+ if (budget.max_llm_calls !== undefined && !(Number.isFinite(budget.max_llm_calls) && budget.max_llm_calls > 0)) {
84
+ errors.push("budget.max_llm_calls, when set, must be a positive number.");
85
+ }
86
+ if (budget.max_runtime_minutes !== undefined && !(Number.isFinite(budget.max_runtime_minutes) && budget.max_runtime_minutes > 0)) {
87
+ errors.push("budget.max_runtime_minutes, when set, must be a positive number.");
88
+ }
89
+
90
+ const identity = policy.identity ?? {};
91
+ if (!identity.project && !identity.team) {
92
+ warnings.push("identity has no project or team - the receipt will not carry org attribution.");
93
+ }
94
+ if (!Array.isArray(verification.allow) || verification.allow.length === 0) {
95
+ warnings.push("verification.allow is empty - any changed path passes the scope check. Declare the paths a legitimate fix should touch.");
96
+ }
97
+ if (verification.guard === "off") {
98
+ warnings.push("verification.guard is off - a tampered verifier will NOT be caught.");
99
+ }
100
+
101
+ return { ok: errors.length === 0, errors, warnings };
102
+ }
103
+
104
+ // Grade an already-built outcome receipt against the policy. Pure: no I/O.
105
+ // BLOCK is the conservative verdict - any single failing condition blocks the
106
+ // mission so a reviewer never has to read past the verdict to know it is unsafe.
107
+ export function evaluatePolicyVerdict(receipt, policy) {
108
+ const reasons = [];
109
+ const budget = policy?.budget ?? {};
110
+ const limit = budget.mission_hard_limit_usd;
111
+
112
+ const integrity = receipt.verificationIntegrity;
113
+ if (integrity && integrity.status === "VERIFIER_COMPROMISED") {
114
+ const tampered = (integrity.violations ?? []).filter((v) =>
115
+ v.startsWith("verifier_file_unchanged:") ||
116
+ v === "package_scripts_unchanged" ||
117
+ v.startsWith("protected_path_untouched:"));
118
+ reasons.push(`VERIFIER_COMPROMISED: the agent changed protected verification evidence${tampered.length ? " (" + tampered.join(", ") + ")" : ""}.`);
119
+ }
120
+
121
+ if (receipt.outcome === "UNVERIFIED") {
122
+ reasons.push("UNVERIFIED: verification did not pass.");
123
+ }
124
+
125
+ const scopeViolations = (integrity?.violations ?? []).filter((v) => v.startsWith("within_allowed_scope:"));
126
+ if (scopeViolations.length) {
127
+ reasons.push(`Out of allowed scope: ${scopeViolations.map((v) => v.replace("within_allowed_scope:", "")).join(", ")}.`);
128
+ }
129
+
130
+ const cost = receipt.cost ?? {};
131
+ if (typeof limit === "number" && typeof cost.actualCostUsd === "number" && cost.actualCostUsd > limit) {
132
+ reasons.push(`Over budget: $${cost.actualCostUsd} spent > $${limit} mission hard limit.`);
133
+ }
134
+ if (cost.budgetGuardTripped) {
135
+ reasons.push("Budget guard tripped: the gateway blocked a call to stay under the mission hard limit.");
136
+ }
137
+
138
+ if (Number.isFinite(budget.max_llm_calls) && typeof cost.llmCalls === "number" && cost.llmCalls > budget.max_llm_calls) {
139
+ reasons.push(`Too many LLM calls: ${cost.llmCalls} > max_llm_calls ${budget.max_llm_calls}.`);
140
+ }
141
+
142
+ const work = receipt.work ?? {};
143
+ if (Number.isFinite(budget.max_runtime_minutes) && typeof work.agentDurationMs === "number") {
144
+ const limitMs = budget.max_runtime_minutes * 60_000;
145
+ if (work.agentDurationMs > limitMs) {
146
+ reasons.push(`Over time budget: ${(work.agentDurationMs / 1000).toFixed(1)}s > max_runtime_minutes ${budget.max_runtime_minutes}.`);
147
+ }
148
+ }
149
+
150
+ return {
151
+ verdict: reasons.length === 0 ? "PASS" : "BLOCKED",
152
+ reasons,
153
+ truth: "calculated_from_policy_and_observed_receipt"
154
+ };
155
+ }
156
+
157
+ // The compact policy block embedded in the receipt: who/what + the rules and
158
+ // the hash of the exact policy text that graded the run, so a reviewer can
159
+ // confirm which rules were in force.
160
+ export function policyMeta(policyResult) {
161
+ const p = policyResult.policy ?? {};
162
+ const identity = p.identity ?? {};
163
+ const mission = p.mission ?? {};
164
+ const budget = p.budget ?? {};
165
+ const verification = p.verification ?? {};
166
+ return {
167
+ schema: "runcap.policy/v1",
168
+ hash: policyResult.hash,
169
+ source: policyResult.source ? path.basename(policyResult.source) : null,
170
+ identity: {
171
+ project: identity.project ?? null,
172
+ team: identity.team ?? null,
173
+ cost_center: identity.cost_center ?? null,
174
+ owner: identity.owner ?? null
175
+ },
176
+ mission: { name: mission.name ?? null, task_class: mission.task_class ?? null },
177
+ limits: {
178
+ mission_hard_limit_usd: budget.mission_hard_limit_usd ?? null,
179
+ max_llm_calls: budget.max_llm_calls ?? null,
180
+ max_runtime_minutes: budget.max_runtime_minutes ?? null,
181
+ guard: verification.guard ?? "strict"
182
+ }
183
+ };
184
+ }
185
+
186
+ // Markdown lines for the printed receipt and the PR summary. Accepts the
187
+ // `receipt.policy` block (policyMeta + verdict + reasons merged).
188
+ export function formatPolicyBlock(receiptPolicy) {
189
+ if (!receiptPolicy) return [];
190
+ const id = receiptPolicy.identity ?? {};
191
+ const limits = receiptPolicy.limits ?? {};
192
+ const who = [id.project && `project ${id.project}`, id.team && `team ${id.team}`, id.cost_center && `cost center ${id.cost_center}`]
193
+ .filter(Boolean).join(" / ") || "no org attribution";
194
+ const lines = [
195
+ `Mission policy: ${receiptPolicy.mission?.name ?? "(unnamed)"}${receiptPolicy.mission?.task_class ? " [" + receiptPolicy.mission.task_class + "]" : ""}`,
196
+ ` ${who}`,
197
+ ` Hard limit: ${limits.mission_hard_limit_usd === null || limits.mission_hard_limit_usd === undefined ? "none" : "$" + Number(limits.mission_hard_limit_usd).toFixed(2)}` +
198
+ `${limits.max_llm_calls ? ", max calls " + limits.max_llm_calls : ""}` +
199
+ `${limits.max_runtime_minutes ? ", max " + limits.max_runtime_minutes + " min" : ""}`,
200
+ ` Policy hash: ${receiptPolicy.hash}`,
201
+ `Mission verdict: ${receiptPolicy.verdict}`
202
+ ];
203
+ if (Array.isArray(receiptPolicy.reasons) && receiptPolicy.reasons.length) {
204
+ lines.push(` Blocked because:`);
205
+ for (const r of receiptPolicy.reasons) lines.push(` - ${r}`);
206
+ }
207
+ return lines;
208
+ }