harnery 0.11.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.
- package/README.md +1 -1
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +2 -0
- package/dist/commands/policy.d.ts +6 -0
- package/dist/commands/policy.d.ts.map +1 -0
- package/dist/commands/policy.js +42 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +18 -1
- package/dist/core/policy/evaluate.d.ts +14 -0
- package/dist/core/policy/evaluate.d.ts.map +1 -0
- package/dist/core/policy/evaluate.js +238 -0
- package/dist/core/policy/file.d.ts +3 -0
- package/dist/core/policy/file.d.ts.map +1 -0
- package/dist/core/policy/file.js +20 -0
- package/dist/core/policy/index.d.ts +5 -0
- package/dist/core/policy/index.d.ts.map +1 -0
- package/dist/core/policy/index.js +3 -0
- package/dist/core/policy/types.d.ts +91 -0
- package/dist/core/policy/types.d.ts.map +1 -0
- package/dist/core/policy/types.js +1 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +261 -35
- package/dist/core/workflow/index.d.ts +1 -1
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/proof.d.ts +7 -0
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +33 -0
- package/dist/core/workflow/types.d.ts +33 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/package.json +6 -1
- package/src/commander.ts +2 -0
- package/src/commands/policy.ts +50 -0
- package/src/commands/workflow.ts +29 -1
- package/src/core/policy/evaluate.ts +311 -0
- package/src/core/policy/file.ts +20 -0
- package/src/core/policy/index.ts +26 -0
- package/src/core/policy/types.ts +107 -0
- package/src/core/workflow/engine.ts +283 -41
- package/src/core/workflow/index.ts +1 -0
- package/src/core/workflow/proof.ts +51 -0
- package/src/core/workflow/types.ts +43 -0
|
@@ -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
|
+
}
|
package/src/commands/workflow.ts
CHANGED
|
@@ -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,8 @@ interface WorkflowRunOpts {
|
|
|
22
24
|
resumeFrom?: string;
|
|
23
25
|
subscriptionOnly?: boolean;
|
|
24
26
|
allowApiBilling?: boolean;
|
|
27
|
+
policy?: string;
|
|
28
|
+
isolation?: PolicyIsolation;
|
|
25
29
|
json?: boolean;
|
|
26
30
|
}
|
|
27
31
|
|
|
@@ -39,7 +43,7 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
|
|
|
39
43
|
workflow
|
|
40
44
|
.command("run <script>")
|
|
41
45
|
.description(
|
|
42
|
-
"Execute a workflow script (plain JS: `export default async ({agent, parallel, stage, log, evidence}) => …`). " +
|
|
46
|
+
"Execute a workflow script (plain JS: `export default async ({agent, parallel, stage, log, evidence, authorize}) => …`). " +
|
|
43
47
|
"Subagents spawn as headless harness-CLI subprocesses, coordination-registered.",
|
|
44
48
|
)
|
|
45
49
|
.option("--max-agents <n>", "Total-agent ceiling for the run (default 50)")
|
|
@@ -63,6 +67,14 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
|
|
|
63
67
|
"Permit an exported API key to override a stored subscription login (per-token billing); " +
|
|
64
68
|
"without this the engine refuses that silent-override state",
|
|
65
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
|
+
)
|
|
66
78
|
.option("--json", "Emit the full RunReport as JSON")
|
|
67
79
|
.action(async (script: string, opts: WorkflowRunOpts) => {
|
|
68
80
|
const coordRoot = findCoordRoot();
|
|
@@ -80,6 +92,16 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
|
|
|
80
92
|
});
|
|
81
93
|
process.exit(1);
|
|
82
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
|
+
}
|
|
83
105
|
// Flag beats config; the two flags contradict each other by design.
|
|
84
106
|
const subscriptionOnly = opts.subscriptionOnly || workflowSubscriptionOnly(coordRoot);
|
|
85
107
|
if (subscriptionOnly && opts.allowApiBilling) {
|
|
@@ -111,6 +133,12 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
|
|
|
111
133
|
{ toolEvidence: adapter.profile.capabilities.toolEvidence },
|
|
112
134
|
]),
|
|
113
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",
|
|
114
142
|
});
|
|
115
143
|
if (opts.json) {
|
|
116
144
|
emit.config({ format: "json" });
|
|
@@ -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";
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export const POLICY_SCHEMA_VERSION = 1 as const;
|
|
2
|
+
|
|
3
|
+
export type PolicyVerdict = "allow" | "ask" | "deny";
|
|
4
|
+
export type PolicyPhase = "dispatch" | "external_mutation";
|
|
5
|
+
export type PolicyIsolation = "shared" | "worktree" | "sandbox" | "remote";
|
|
6
|
+
export type PolicyNetworkAccess = "enabled" | "disabled" | "unknown";
|
|
7
|
+
|
|
8
|
+
/** Host-owned policy document. Workflow metadata cannot supply this value. */
|
|
9
|
+
export interface PolicySpec {
|
|
10
|
+
schema_version?: typeof POLICY_SCHEMA_VERSION;
|
|
11
|
+
name?: string;
|
|
12
|
+
max_cost_usd?: number;
|
|
13
|
+
/** Applied when a cost ceiling exists but the host cannot price a dispatch. */
|
|
14
|
+
unknown_cost?: Exclude<PolicyVerdict, "allow">;
|
|
15
|
+
allowed_harnesses?: readonly string[];
|
|
16
|
+
allowed_models?: readonly string[];
|
|
17
|
+
/** Absolute paths, or paths relative to the policy file. */
|
|
18
|
+
allowed_paths?: readonly string[];
|
|
19
|
+
network?: PolicyVerdict;
|
|
20
|
+
external_actions?: PolicyVerdict;
|
|
21
|
+
/** Allowed host-declared execution boundaries. No strength ordering is inferred. */
|
|
22
|
+
allowed_isolation?: readonly PolicyIsolation[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface NormalizedPolicy {
|
|
26
|
+
schema_version: typeof POLICY_SCHEMA_VERSION;
|
|
27
|
+
name: string;
|
|
28
|
+
max_cost_usd?: number;
|
|
29
|
+
unknown_cost: Exclude<PolicyVerdict, "allow">;
|
|
30
|
+
allowed_harnesses?: readonly string[];
|
|
31
|
+
allowed_models?: readonly string[];
|
|
32
|
+
allowed_paths?: readonly string[];
|
|
33
|
+
network: PolicyVerdict;
|
|
34
|
+
external_actions: PolicyVerdict;
|
|
35
|
+
allowed_isolation?: readonly PolicyIsolation[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PolicyRequest {
|
|
39
|
+
phase: PolicyPhase;
|
|
40
|
+
action: string;
|
|
41
|
+
path?: string;
|
|
42
|
+
harness?: string;
|
|
43
|
+
model?: string;
|
|
44
|
+
effort?: string;
|
|
45
|
+
max_attempts?: number;
|
|
46
|
+
max_turns?: number;
|
|
47
|
+
timeout_ms?: number;
|
|
48
|
+
/** Prompt size for host estimation. Prompt content is never included. */
|
|
49
|
+
prompt_bytes?: number;
|
|
50
|
+
isolation: PolicyIsolation;
|
|
51
|
+
network_access: PolicyNetworkAccess;
|
|
52
|
+
service?: string;
|
|
53
|
+
target?: string;
|
|
54
|
+
current_cost_usd?: number;
|
|
55
|
+
projected_cost_usd?: number | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type PolicyRequestSummary = PolicyRequest;
|
|
59
|
+
|
|
60
|
+
export interface PolicyRuleResult {
|
|
61
|
+
code: string;
|
|
62
|
+
verdict: PolicyVerdict;
|
|
63
|
+
reason: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface PolicyEvaluation {
|
|
67
|
+
verdict: PolicyVerdict;
|
|
68
|
+
reason: string;
|
|
69
|
+
rules: PolicyRuleResult[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface PolicyAskResolution {
|
|
73
|
+
verdict: "allow" | "deny";
|
|
74
|
+
reason?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type PolicyAskResolver = (
|
|
78
|
+
request: Readonly<PolicyRequestSummary>,
|
|
79
|
+
evaluation: Readonly<PolicyEvaluation>,
|
|
80
|
+
) => Promise<PolicyAskResolution> | PolicyAskResolution;
|
|
81
|
+
|
|
82
|
+
export interface PolicyDecision {
|
|
83
|
+
id: string;
|
|
84
|
+
checked_at: string;
|
|
85
|
+
policy: string;
|
|
86
|
+
phase: PolicyPhase;
|
|
87
|
+
initial_verdict: PolicyVerdict;
|
|
88
|
+
verdict: "allow" | "deny";
|
|
89
|
+
resolved_by: "policy" | "host" | "fail_closed";
|
|
90
|
+
reason: string;
|
|
91
|
+
rule_codes: string[];
|
|
92
|
+
request: PolicyRequestSummary;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ExternalMutationRequest {
|
|
96
|
+
/** Short verb phrase, for example "publish release" or "write report". */
|
|
97
|
+
action: string;
|
|
98
|
+
path?: string;
|
|
99
|
+
network?: boolean;
|
|
100
|
+
service?: string;
|
|
101
|
+
/** Bounded destination descriptor. Do not include credentials or payloads. */
|
|
102
|
+
target?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type DispatchCostEstimator = (
|
|
106
|
+
request: Readonly<PolicyRequestSummary>,
|
|
107
|
+
) => Promise<number | null> | number | null;
|