harnery 0.10.0 → 0.12.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 (44) hide show
  1. package/README.md +1 -1
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +2 -0
  4. package/dist/commands/policy.d.ts +6 -0
  5. package/dist/commands/policy.d.ts.map +1 -0
  6. package/dist/commands/policy.js +42 -0
  7. package/dist/commands/workflow.d.ts.map +1 -1
  8. package/dist/commands/workflow.js +62 -2
  9. package/dist/core/policy/evaluate.d.ts +14 -0
  10. package/dist/core/policy/evaluate.d.ts.map +1 -0
  11. package/dist/core/policy/evaluate.js +238 -0
  12. package/dist/core/policy/file.d.ts +3 -0
  13. package/dist/core/policy/file.d.ts.map +1 -0
  14. package/dist/core/policy/file.js +20 -0
  15. package/dist/core/policy/index.d.ts +5 -0
  16. package/dist/core/policy/index.d.ts.map +1 -0
  17. package/dist/core/policy/index.js +3 -0
  18. package/dist/core/policy/types.d.ts +91 -0
  19. package/dist/core/policy/types.d.ts.map +1 -0
  20. package/dist/core/policy/types.js +1 -0
  21. package/dist/core/workflow/engine.d.ts +5 -0
  22. package/dist/core/workflow/engine.d.ts.map +1 -1
  23. package/dist/core/workflow/engine.js +412 -47
  24. package/dist/core/workflow/index.d.ts +5 -0
  25. package/dist/core/workflow/index.d.ts.map +1 -0
  26. package/dist/core/workflow/index.js +3 -0
  27. package/dist/core/workflow/proof.d.ts +49 -0
  28. package/dist/core/workflow/proof.d.ts.map +1 -0
  29. package/dist/core/workflow/proof.js +375 -0
  30. package/dist/core/workflow/types.d.ts +180 -0
  31. package/dist/core/workflow/types.d.ts.map +1 -1
  32. package/dist/core/workflow/types.js +1 -1
  33. package/package.json +16 -6
  34. package/src/commander.ts +2 -0
  35. package/src/commands/policy.ts +50 -0
  36. package/src/commands/workflow.ts +82 -2
  37. package/src/core/policy/evaluate.ts +311 -0
  38. package/src/core/policy/file.ts +20 -0
  39. package/src/core/policy/index.ts +26 -0
  40. package/src/core/policy/types.ts +107 -0
  41. package/src/core/workflow/engine.ts +452 -53
  42. package/src/core/workflow/index.ts +44 -0
  43. package/src/core/workflow/proof.ts +485 -0
  44. package/src/core/workflow/types.ts +210 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harnery",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Multi-agent coordination for AI coding agents - Claude Code, Cursor, and Codex.",
5
5
  "license": "MIT",
6
6
  "author": "Ryan Kelly",
@@ -62,6 +62,16 @@
62
62
  "types": "./dist/core/context/index.d.ts",
63
63
  "import": "./dist/core/context/index.js"
64
64
  },
65
+ "./core/workflow": {
66
+ "bun": "./src/core/workflow/index.ts",
67
+ "types": "./dist/core/workflow/index.d.ts",
68
+ "import": "./dist/core/workflow/index.js"
69
+ },
70
+ "./core/policy": {
71
+ "bun": "./src/core/policy/index.ts",
72
+ "types": "./dist/core/policy/index.d.ts",
73
+ "import": "./dist/core/policy/index.js"
74
+ },
65
75
  "./lib/agent-browser": {
66
76
  "bun": "./src/lib/agent-browser/index.ts",
67
77
  "types": "./dist/lib/agent-browser/index.d.ts",
@@ -125,7 +135,7 @@
125
135
  "!src/**/*.test.tsx"
126
136
  ],
127
137
  "engines": {
128
- "node": ">=20"
138
+ "node": ">=20.19.0"
129
139
  },
130
140
  "publishConfig": {
131
141
  "access": "public",
@@ -147,17 +157,17 @@
147
157
  "test:web": "cd web && bun install && bun test",
148
158
  "test:integration": "bash tests/integration/run.sh",
149
159
  "smoke": "node scripts/smoke-test.mjs",
150
- "docs:dev": "bun --cwd docs run dev",
151
- "docs:build": "bun --cwd docs run build",
160
+ "docs:dev": "bun run --bun --cwd docs dev",
161
+ "docs:build": "bun run --bun --cwd docs build",
152
162
  "brand:showcase": "node scripts/build-brand-showcase.mjs"
153
163
  },
154
164
  "dependencies": {
155
165
  "@mozilla/readability": "^0.6.0",
156
- "commander": "^15.0.0",
166
+ "commander": "^14.0.3",
157
167
  "gpt-tokenizer": "^3.4.0",
158
168
  "js-yaml": "^4.1.1",
159
169
  "linkedom": "^0.18.0",
160
- "mailparser": "^3.9.8",
170
+ "mailparser": "^3.9.14",
161
171
  "pixelmatch": "^7.2.0",
162
172
  "playwright": "^1.59.1",
163
173
  "pngjs": "^7.0.0",
package/src/commander.ts CHANGED
@@ -42,6 +42,7 @@ import { registerGrepCommand } from "./commands/grep.ts";
42
42
  import { registerHarnessCommand } from "./commands/harness.ts";
43
43
  import { registerInitCommand } from "./commands/init.ts";
44
44
  import { registerOutlineCommand } from "./commands/outline.ts";
45
+ import { registerPolicyCommand } from "./commands/policy.ts";
45
46
  import { registerPresenceCommand } from "./commands/presence.ts";
46
47
  import { registerReadCommand } from "./commands/read.ts";
47
48
  import { registerRelayCommand } from "./commands/relay.ts";
@@ -264,6 +265,7 @@ export function createHarneryProgram(opts: HarneryContextOpts = {}): Command {
264
265
  registerEditBatchCommand(program, emit);
265
266
  registerGrepCommand(program, emit, opts.context);
266
267
  registerHarnessCommand(program, emit);
268
+ registerPolicyCommand(program, emit);
267
269
  registerCookiesCommand(program, emit);
268
270
  registerFetchCommand(program, emit, opts.context);
269
271
  registerReadCommand(program, emit);
@@ -0,0 +1,50 @@
1
+ import type { Command } from "commander";
2
+ import type { EmitContext } from "../commander.ts";
3
+ import type { NormalizedPolicy } from "../core/policy/index.ts";
4
+ import { loadPolicyFile, policyDigest } from "../core/policy/index.ts";
5
+
6
+ interface PolicyCheckOpts {
7
+ json?: boolean;
8
+ }
9
+
10
+ export function registerPolicyCommand(program: Command, emit: EmitContext): void {
11
+ const command = program
12
+ .command("policy")
13
+ .description("Inspect host-enforced workflow policy documents.");
14
+
15
+ command
16
+ .command("check <file>")
17
+ .description("Validate and print a normalized policy without running a workflow.")
18
+ .option("--json", "Emit the normalized policy and digest as JSON")
19
+ .action((file: string, opts: PolicyCheckOpts) => {
20
+ try {
21
+ const policy = loadPolicyFile(file);
22
+ const result = { policy, sha256: policyDigest(policy) };
23
+ if (opts.json) {
24
+ emit.config({ format: "json" });
25
+ emit.data(result);
26
+ } else {
27
+ emit.text(renderPolicy(policy, result.sha256));
28
+ }
29
+ } catch (error) {
30
+ emit.error({ code: "policy_invalid", message: (error as Error).message });
31
+ emit.setExitCode(1);
32
+ }
33
+ });
34
+ }
35
+
36
+ export function renderPolicy(policy: Readonly<NormalizedPolicy>, digest: string): string {
37
+ const lines = [
38
+ `${policy.name} (schema ${policy.schema_version})`,
39
+ `sha256: ${digest}`,
40
+ `cost ceiling: ${policy.max_cost_usd === undefined ? "none" : `$${policy.max_cost_usd.toFixed(4)}`}`,
41
+ `unknown cost: ${policy.unknown_cost}`,
42
+ `network: ${policy.network}`,
43
+ `external actions: ${policy.external_actions}`,
44
+ `harnesses: ${policy.allowed_harnesses?.join(", ") ?? "any"}`,
45
+ `models: ${policy.allowed_models?.join(", ") ?? "any"}`,
46
+ `paths: ${policy.allowed_paths?.join(", ") ?? "any"}`,
47
+ `isolation: ${policy.allowed_isolation?.join(", ") ?? "any"}`,
48
+ ];
49
+ return `${lines.join("\n")}\n`;
50
+ }
@@ -3,6 +3,8 @@ import type { EmitContext } from "../commander.ts";
3
3
  import { workflowSubscriptionOnly } from "../core/config.ts";
4
4
  import { createBuiltinHarnessRegistry } from "../core/harnesses/index.ts";
5
5
  import { findCoordRoot } from "../core/hooks/resolve/coord-root.ts";
6
+ import type { PolicyIsolation } from "../core/policy/index.ts";
7
+ import { loadPolicyFile } from "../core/policy/index.ts";
6
8
 
7
9
  /**
8
10
  * `workflow run <script>`: execute a workflow script — bounded, schema-gated,
@@ -22,6 +24,12 @@ interface WorkflowRunOpts {
22
24
  resumeFrom?: string;
23
25
  subscriptionOnly?: boolean;
24
26
  allowApiBilling?: boolean;
27
+ policy?: string;
28
+ isolation?: PolicyIsolation;
29
+ json?: boolean;
30
+ }
31
+
32
+ interface WorkflowProofOpts {
25
33
  json?: boolean;
26
34
  }
27
35
 
@@ -35,7 +43,7 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
35
43
  workflow
36
44
  .command("run <script>")
37
45
  .description(
38
- "Execute a workflow script (plain JS: `export default async ({agent, parallel, stage, log}) => …`). " +
46
+ "Execute a workflow script (plain JS: `export default async ({agent, parallel, stage, log, evidence, authorize}) => …`). " +
39
47
  "Subagents spawn as headless harness-CLI subprocesses, coordination-registered.",
40
48
  )
41
49
  .option("--max-agents <n>", "Total-agent ceiling for the run (default 50)")
@@ -59,6 +67,14 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
59
67
  "Permit an exported API key to override a stored subscription login (per-token billing); " +
60
68
  "without this the engine refuses that silent-override state",
61
69
  )
70
+ .option(
71
+ "--policy <file>",
72
+ "Host policy JSON/JSONC applied before dispatch and external mutation",
73
+ )
74
+ .option(
75
+ "--isolation <mode>",
76
+ "Host-created execution boundary: shared | worktree | sandbox | remote (default shared)",
77
+ )
62
78
  .option("--json", "Emit the full RunReport as JSON")
63
79
  .action(async (script: string, opts: WorkflowRunOpts) => {
64
80
  const coordRoot = findCoordRoot();
@@ -76,6 +92,16 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
76
92
  });
77
93
  process.exit(1);
78
94
  }
95
+ if (
96
+ opts.isolation &&
97
+ !(["shared", "worktree", "sandbox", "remote"] as const).includes(opts.isolation)
98
+ ) {
99
+ emit.error({
100
+ code: "bad_isolation",
101
+ message: `unknown isolation ${JSON.stringify(opts.isolation)} (expected: shared | worktree | sandbox | remote)`,
102
+ });
103
+ process.exit(1);
104
+ }
79
105
  // Flag beats config; the two flags contradict each other by design.
80
106
  const subscriptionOnly = opts.subscriptionOnly || workflowSubscriptionOnly(coordRoot);
81
107
  if (subscriptionOnly && opts.allowApiBilling) {
@@ -99,6 +125,20 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
99
125
  maxAgents: opts.maxAgents ? Number.parseInt(opts.maxAgents, 10) : undefined,
100
126
  concurrency: opts.concurrency ? Number.parseInt(opts.concurrency, 10) : undefined,
101
127
  cwd: opts.cwd,
128
+ harnessEvidence: Object.fromEntries(
129
+ registry
130
+ .list()
131
+ .map((adapter) => [
132
+ adapter.profile.id,
133
+ { toolEvidence: adapter.profile.capabilities.toolEvidence },
134
+ ]),
135
+ ),
136
+ policy: opts.policy ? loadPolicyFile(opts.policy) : undefined,
137
+ isolation: opts.isolation,
138
+ // CLI harness subprocesses inherit the host network. A policy that
139
+ // forbids network must therefore deny dispatch unless a future host
140
+ // adapter creates and declares a network-disabled boundary.
141
+ networkAccess: "enabled",
102
142
  });
103
143
  if (opts.json) {
104
144
  emit.config({ format: "json" });
@@ -112,11 +152,51 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
112
152
  emit.text(
113
153
  `run ${report.runId} (${report.name}) finished: ${report.agentsSpawned} agent(s)${cachedPart}, ` +
114
154
  `$${report.costUsd.toFixed(4)}, ${Math.round(report.durationMs / 1000)}s\n${billingPart}` +
155
+ `acceptance: ${report.acceptance.satisfied} satisfied, ${report.acceptance.unsatisfied} unsatisfied, ` +
156
+ `${report.acceptance.unknown} unknown\n` +
115
157
  `journal: ${report.journalPath}\n` +
158
+ `proof: ${report.proofPath}\n` +
116
159
  `result: ${typeof report.result === "string" ? report.result : JSON.stringify(report.result, null, 2)}\n`,
117
160
  );
118
161
  } catch (err) {
119
- emit.error({ code: "workflow_failed", message: (err as Error).message });
162
+ const proofPath =
163
+ typeof err === "object" && err !== null && "proofPath" in err
164
+ ? String(err.proofPath)
165
+ : undefined;
166
+ emit.error({
167
+ code: "workflow_failed",
168
+ message: `${(err as Error).message}${proofPath ? `\nproof: ${proofPath}` : ""}`,
169
+ });
170
+ process.exit(1);
171
+ }
172
+ });
173
+
174
+ workflow
175
+ .command("proof <run-id>")
176
+ .description("Show the bounded proof packet for a completed workflow run.")
177
+ .option("--json", "Emit the stored proof packet as JSON")
178
+ .action(async (runId: string, opts: WorkflowProofOpts) => {
179
+ const coordRoot = findCoordRoot();
180
+ if (!coordRoot) {
181
+ emit.error({
182
+ code: "no_coord_root",
183
+ message: "no .harnery/ coordination root found; run `init` first",
184
+ });
185
+ process.exit(1);
186
+ }
187
+ try {
188
+ const { readWorkflowProof, renderWorkflowProof } = await import(
189
+ "../core/workflow/proof.ts"
190
+ );
191
+ const proof = readWorkflowProof(coordRoot, runId);
192
+ if (opts.json) {
193
+ emit.config({ format: "json" });
194
+ emit.data(proof);
195
+ return;
196
+ }
197
+ emit.text(renderWorkflowProof(proof));
198
+ } catch (err) {
199
+ emit.error({ code: "workflow_proof_failed", message: (err as Error).message });
120
200
  process.exit(1);
121
201
  }
122
202
  });
@@ -0,0 +1,311 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isAbsolute, relative, resolve } from "node:path";
3
+ import type {
4
+ NormalizedPolicy,
5
+ PolicyEvaluation,
6
+ PolicyIsolation,
7
+ PolicyRequest,
8
+ PolicyRequestSummary,
9
+ PolicyRuleResult,
10
+ PolicySpec,
11
+ PolicyVerdict,
12
+ } from "./types.ts";
13
+ import { POLICY_SCHEMA_VERSION } from "./types.ts";
14
+
15
+ const VERDICTS = ["allow", "ask", "deny"] as const;
16
+ const ISOLATIONS = ["shared", "worktree", "sandbox", "remote"] as const;
17
+ const MAX_NAME = 200;
18
+ const MAX_FIELD = 500;
19
+ const MAX_LIST = 100;
20
+ const POLICY_FIELDS = new Set([
21
+ "schema_version",
22
+ "name",
23
+ "max_cost_usd",
24
+ "unknown_cost",
25
+ "allowed_harnesses",
26
+ "allowed_models",
27
+ "allowed_paths",
28
+ "network",
29
+ "external_actions",
30
+ "allowed_isolation",
31
+ ]);
32
+
33
+ export class PolicyDeniedError extends Error {
34
+ readonly decisionId: string;
35
+
36
+ constructor(message: string, decisionId: string) {
37
+ super(message);
38
+ this.name = "PolicyDeniedError";
39
+ this.decisionId = decisionId;
40
+ }
41
+ }
42
+
43
+ export function normalizePolicy(
44
+ input: PolicySpec,
45
+ options: { baseDir?: string } = {},
46
+ ): Readonly<NormalizedPolicy> {
47
+ if (!isRecord(input)) throw new Error("policy must be an object");
48
+ const unknownFields = Object.keys(input).filter((field) => !POLICY_FIELDS.has(field));
49
+ if (unknownFields.length > 0) {
50
+ throw new Error(`policy contains unknown field(s): ${unknownFields.sort().join(", ")}`);
51
+ }
52
+ const schemaVersion = input.schema_version ?? POLICY_SCHEMA_VERSION;
53
+ if (schemaVersion !== POLICY_SCHEMA_VERSION) {
54
+ throw new Error(`unsupported policy schema_version ${JSON.stringify(schemaVersion)}`);
55
+ }
56
+ const baseDir = resolve(options.baseDir ?? process.cwd());
57
+ const normalized: NormalizedPolicy = {
58
+ schema_version: POLICY_SCHEMA_VERSION,
59
+ name: boundedString(input.name ?? "workflow policy", "policy name", MAX_NAME),
60
+ max_cost_usd: optionalMoney(input.max_cost_usd, "max_cost_usd"),
61
+ unknown_cost: enumValue(input.unknown_cost ?? "deny", ["ask", "deny"], "unknown_cost"),
62
+ allowed_harnesses: optionalStringList(input.allowed_harnesses, "allowed_harnesses"),
63
+ allowed_models: optionalStringList(input.allowed_models, "allowed_models"),
64
+ allowed_paths: optionalPathList(input.allowed_paths, baseDir),
65
+ network: enumValue(input.network ?? "deny", VERDICTS, "network"),
66
+ external_actions: enumValue(input.external_actions ?? "deny", VERDICTS, "external_actions"),
67
+ allowed_isolation: optionalEnumList(input.allowed_isolation, ISOLATIONS, "allowed_isolation"),
68
+ };
69
+ return deepFreeze(normalized);
70
+ }
71
+
72
+ export function policyDigest(policy: NormalizedPolicy): string {
73
+ return createHash("sha256").update(JSON.stringify(policy)).digest("hex");
74
+ }
75
+
76
+ export function evaluatePolicy(
77
+ policy: Readonly<NormalizedPolicy>,
78
+ rawRequest: PolicyRequest,
79
+ ): PolicyEvaluation {
80
+ const request = summarizePolicyRequest(rawRequest);
81
+ const rules: PolicyRuleResult[] = [];
82
+ const add = (code: string, verdict: PolicyVerdict, reason: string): void => {
83
+ rules.push({ code, verdict, reason });
84
+ };
85
+
86
+ if (policy.allowed_harnesses && request.phase === "dispatch") {
87
+ if (!request.harness || !policy.allowed_harnesses.includes(request.harness)) {
88
+ add(
89
+ "harness_not_allowed",
90
+ "deny",
91
+ `harness ${JSON.stringify(request.harness ?? "unknown")} is not allowed`,
92
+ );
93
+ }
94
+ }
95
+ if (policy.allowed_models && request.phase === "dispatch") {
96
+ if (!request.model || !policy.allowed_models.includes(request.model)) {
97
+ add(
98
+ "model_not_allowed",
99
+ "deny",
100
+ `model ${JSON.stringify(request.model ?? "unknown")} is not allowed`,
101
+ );
102
+ }
103
+ }
104
+ if (policy.allowed_paths) {
105
+ if (!request.path || !policy.allowed_paths.some((root) => contains(root, request.path!))) {
106
+ add(
107
+ "path_not_allowed",
108
+ "deny",
109
+ `path ${JSON.stringify(request.path ?? "unknown")} is outside the allowed roots`,
110
+ );
111
+ }
112
+ }
113
+ if (policy.allowed_isolation && !policy.allowed_isolation.includes(request.isolation)) {
114
+ add(
115
+ "isolation_not_allowed",
116
+ "deny",
117
+ `isolation ${JSON.stringify(request.isolation)} is not allowed`,
118
+ );
119
+ }
120
+ if (request.network_access !== "disabled" && policy.network !== "allow") {
121
+ add(
122
+ request.network_access === "unknown" ? "network_unknown" : "network_restricted",
123
+ policy.network,
124
+ request.network_access === "unknown"
125
+ ? "network access is unknown"
126
+ : "network access requires policy authorization",
127
+ );
128
+ }
129
+ if (request.phase === "external_mutation" && policy.external_actions !== "allow") {
130
+ add(
131
+ "external_action_restricted",
132
+ policy.external_actions,
133
+ "external mutation requires policy authorization",
134
+ );
135
+ }
136
+ if (policy.max_cost_usd !== undefined && request.phase === "dispatch") {
137
+ const current = request.current_cost_usd ?? 0;
138
+ if (current >= policy.max_cost_usd) {
139
+ add(
140
+ "cost_budget_exhausted",
141
+ "deny",
142
+ `run cost $${current.toFixed(4)} has reached the $${policy.max_cost_usd.toFixed(4)} ceiling`,
143
+ );
144
+ } else if (request.projected_cost_usd === null || request.projected_cost_usd === undefined) {
145
+ add(
146
+ "dispatch_cost_unknown",
147
+ policy.unknown_cost,
148
+ `dispatch cost is unknown under a $${policy.max_cost_usd.toFixed(4)} ceiling`,
149
+ );
150
+ } else if (current + request.projected_cost_usd > policy.max_cost_usd) {
151
+ add(
152
+ "cost_budget_exceeded",
153
+ "deny",
154
+ `projected run cost $${(current + request.projected_cost_usd).toFixed(4)} exceeds the $${policy.max_cost_usd.toFixed(4)} ceiling`,
155
+ );
156
+ }
157
+ }
158
+
159
+ if (rules.length === 0) {
160
+ rules.push({ code: "policy_allow", verdict: "allow", reason: "all configured rules allow" });
161
+ }
162
+ const verdict = rules.some((rule) => rule.verdict === "deny")
163
+ ? "deny"
164
+ : rules.some((rule) => rule.verdict === "ask")
165
+ ? "ask"
166
+ : "allow";
167
+ const decisive = rules.filter((rule) => rule.verdict === verdict);
168
+ return {
169
+ verdict,
170
+ reason: decisive.map((rule) => rule.reason).join("; "),
171
+ rules,
172
+ };
173
+ }
174
+
175
+ /** Bound and normalize the receipt-safe subset before it reaches a journal or proof. */
176
+ export function summarizePolicyRequest(input: PolicyRequest): PolicyRequestSummary {
177
+ if (!isRecord(input)) throw new Error("policy request must be an object");
178
+ const phase = enumValue(input.phase, ["dispatch", "external_mutation"], "policy phase");
179
+ const isolation = enumValue(input.isolation, ISOLATIONS, "policy isolation");
180
+ const networkAccess = enumValue(
181
+ input.network_access,
182
+ ["enabled", "disabled", "unknown"],
183
+ "policy network_access",
184
+ );
185
+ return {
186
+ phase,
187
+ action: boundedString(input.action, "policy action", MAX_FIELD),
188
+ path: optionalBounded(input.path, "policy path"),
189
+ harness: optionalBounded(input.harness, "policy harness"),
190
+ model: optionalBounded(input.model, "policy model"),
191
+ effort: optionalBounded(input.effort, "policy effort"),
192
+ max_attempts: optionalWholeNumber(input.max_attempts, "policy max_attempts", 1),
193
+ max_turns: optionalWholeNumber(input.max_turns, "policy max_turns", 1),
194
+ timeout_ms: optionalWholeNumber(input.timeout_ms, "policy timeout_ms", 1),
195
+ prompt_bytes: optionalWholeNumber(input.prompt_bytes, "policy prompt_bytes", 0),
196
+ isolation,
197
+ network_access: networkAccess,
198
+ service: optionalBounded(input.service, "policy service"),
199
+ target: optionalBounded(safeTarget(input.target), "policy target"),
200
+ current_cost_usd: optionalMoney(input.current_cost_usd, "current_cost_usd"),
201
+ projected_cost_usd:
202
+ input.projected_cost_usd === null
203
+ ? null
204
+ : optionalMoney(input.projected_cost_usd, "projected_cost_usd"),
205
+ };
206
+ }
207
+
208
+ function contains(root: string, candidate: string): boolean {
209
+ const rel = relative(resolve(root), resolve(candidate));
210
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
211
+ }
212
+
213
+ function optionalPathList(value: unknown, baseDir: string): readonly string[] | undefined {
214
+ const values = optionalStringList(value, "allowed_paths");
215
+ if (!values) return undefined;
216
+ return [...new Set(values.map((item) => resolve(baseDir, item)))].sort();
217
+ }
218
+
219
+ function optionalStringList(value: unknown, field: string): readonly string[] | undefined {
220
+ if (value === undefined) return undefined;
221
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_LIST) {
222
+ throw new Error(`${field} must be a non-empty array with at most ${MAX_LIST} entries`);
223
+ }
224
+ return [
225
+ ...new Set(value.map((item, index) => boundedString(item, `${field}[${index}]`, MAX_FIELD))),
226
+ ].sort();
227
+ }
228
+
229
+ function optionalEnumList<T extends string>(
230
+ value: unknown,
231
+ allowed: readonly T[],
232
+ field: string,
233
+ ): readonly T[] | undefined {
234
+ if (value === undefined) return undefined;
235
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_LIST) {
236
+ throw new Error(`${field} must be a non-empty array with at most ${MAX_LIST} entries`);
237
+ }
238
+ return [...new Set(value.map((item) => enumValue(item, allowed, field)))].sort();
239
+ }
240
+
241
+ function enumValue<T extends string>(value: unknown, values: readonly T[], field: string): T {
242
+ if (typeof value !== "string" || !values.includes(value as T)) {
243
+ throw new Error(`${field} must be one of: ${values.join(", ")}`);
244
+ }
245
+ return value as T;
246
+ }
247
+
248
+ function optionalMoney(value: unknown, field: string): number | undefined {
249
+ if (value === undefined) return undefined;
250
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
251
+ throw new Error(`${field} must be a finite non-negative number`);
252
+ }
253
+ return value;
254
+ }
255
+
256
+ function optionalWholeNumber(value: unknown, field: string, minimum: number): number | undefined {
257
+ if (value === undefined) return undefined;
258
+ if (!Number.isSafeInteger(value) || (value as number) < minimum) {
259
+ throw new Error(`${field} must be a safe integer greater than or equal to ${minimum}`);
260
+ }
261
+ return value as number;
262
+ }
263
+
264
+ function boundedString(value: unknown, field: string, max: number): string {
265
+ if (typeof value !== "string") throw new Error(`${field} must be a string`);
266
+ const normalized = withoutControls(value).replace(/\s+/g, " ").trim();
267
+ if (!normalized) throw new Error(`${field} must not be empty`);
268
+ if (normalized.length > max) throw new Error(`${field} exceeds ${max} characters`);
269
+ return normalized;
270
+ }
271
+
272
+ function optionalBounded(value: unknown, field: string): string | undefined {
273
+ return value === undefined ? undefined : boundedString(value, field, MAX_FIELD);
274
+ }
275
+
276
+ function safeTarget(value: unknown): unknown {
277
+ if (typeof value !== "string") return value;
278
+ try {
279
+ const url = new URL(value);
280
+ return `${url.origin}${url.pathname}`;
281
+ } catch {
282
+ const query = value.indexOf("?");
283
+ const fragment = value.indexOf("#");
284
+ const boundary = Math.min(
285
+ query === -1 ? value.length : query,
286
+ fragment === -1 ? value.length : fragment,
287
+ );
288
+ return value.slice(0, boundary);
289
+ }
290
+ }
291
+
292
+ function isRecord(value: unknown): value is Record<string, unknown> {
293
+ return typeof value === "object" && value !== null && !Array.isArray(value);
294
+ }
295
+
296
+ function withoutControls(value: string): string {
297
+ return Array.from(value, (character) => {
298
+ const code = character.charCodeAt(0);
299
+ return code < 32 || code === 127 ? " " : character;
300
+ }).join("");
301
+ }
302
+
303
+ function deepFreeze<T>(value: T): Readonly<T> {
304
+ if (value && typeof value === "object") {
305
+ for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child);
306
+ Object.freeze(value);
307
+ }
308
+ return value;
309
+ }
310
+
311
+ export type { PolicyIsolation };
@@ -0,0 +1,20 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { stripJsonComments } from "../config.ts";
4
+ import { normalizePolicy } from "./evaluate.ts";
5
+ import type { NormalizedPolicy, PolicySpec } from "./types.ts";
6
+
7
+ export function loadPolicyFile(path: string): Readonly<NormalizedPolicy> {
8
+ const absolute = resolve(path);
9
+ let parsed: PolicySpec;
10
+ try {
11
+ parsed = JSON.parse(stripJsonComments(readFileSync(absolute, "utf8"))) as PolicySpec;
12
+ } catch (error) {
13
+ throw new Error(`cannot parse policy at ${absolute}: ${(error as Error).message}`);
14
+ }
15
+ try {
16
+ return normalizePolicy(parsed, { baseDir: dirname(absolute) });
17
+ } catch (error) {
18
+ throw new Error(`invalid policy at ${absolute}: ${(error as Error).message}`);
19
+ }
20
+ }
@@ -0,0 +1,26 @@
1
+ export {
2
+ evaluatePolicy,
3
+ normalizePolicy,
4
+ PolicyDeniedError,
5
+ policyDigest,
6
+ summarizePolicyRequest,
7
+ } from "./evaluate.ts";
8
+ export { loadPolicyFile } from "./file.ts";
9
+ export type {
10
+ DispatchCostEstimator,
11
+ ExternalMutationRequest,
12
+ NormalizedPolicy,
13
+ PolicyAskResolution,
14
+ PolicyAskResolver,
15
+ PolicyDecision,
16
+ PolicyEvaluation,
17
+ PolicyIsolation,
18
+ PolicyNetworkAccess,
19
+ PolicyPhase,
20
+ PolicyRequest,
21
+ PolicyRequestSummary,
22
+ PolicyRuleResult,
23
+ PolicySpec,
24
+ PolicyVerdict,
25
+ } from "./types.ts";
26
+ export { POLICY_SCHEMA_VERSION } from "./types.ts";