harnery 0.11.0 → 0.12.1
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/browse.js +1 -3
- package/dist/commands/eml.d.ts.map +1 -1
- package/dist/commands/eml.js +11 -50
- 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/agents/coord-client.d.ts +7 -0
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +27 -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/scratch/index.d.ts.map +1 -1
- package/dist/core/scratch/index.js +14 -4
- 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/dist/lib/browser/client.d.ts +1 -1
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +7 -1
- package/package.json +9 -4
- package/src/commander.ts +2 -0
- package/src/commands/browse.ts +1 -5
- package/src/commands/eml.ts +11 -62
- package/src/commands/policy.ts +50 -0
- package/src/commands/workflow.ts +29 -1
- package/src/core/agents/coord-client.ts +28 -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/scratch/index.ts +20 -4
- 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
- package/src/lib/browser/client.ts +6 -1
|
@@ -22,6 +22,14 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "n
|
|
|
22
22
|
import { isAbsolute, join, resolve } from "node:path";
|
|
23
23
|
import { pathToFileURL } from "node:url";
|
|
24
24
|
import { snapshotRepo } from "../context/index.ts";
|
|
25
|
+
import type { ExternalMutationRequest, PolicyDecision, PolicyRequest } from "../policy/index.ts";
|
|
26
|
+
import {
|
|
27
|
+
evaluatePolicy,
|
|
28
|
+
normalizePolicy,
|
|
29
|
+
PolicyDeniedError,
|
|
30
|
+
policyDigest,
|
|
31
|
+
summarizePolicyRequest,
|
|
32
|
+
} from "../policy/index.ts";
|
|
25
33
|
import { type BillingProbe, probeBilling } from "./billing.ts";
|
|
26
34
|
import {
|
|
27
35
|
buildWorkflowProof,
|
|
@@ -50,6 +58,8 @@ const DEFAULT_CONCURRENCY = 4;
|
|
|
50
58
|
const DEFAULT_MAX_ATTEMPTS = 2;
|
|
51
59
|
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
52
60
|
const DEFAULT_MAX_TURNS = 25;
|
|
61
|
+
const DEFAULT_POLICY_ASK_TIMEOUT_MS = 60_000;
|
|
62
|
+
const MAX_POLICY_DECISIONS = 50;
|
|
53
63
|
|
|
54
64
|
export class WorkflowRunError extends Error {
|
|
55
65
|
readonly runId: string;
|
|
@@ -84,6 +94,9 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
84
94
|
const cwd = opts.cwd ?? opts.coordRoot;
|
|
85
95
|
const log = opts.onLog ?? ((line: string) => process.stderr.write(`${line}\n`));
|
|
86
96
|
const defaultHarness: HarnessName = opts.defaultHarness ?? "claude-code";
|
|
97
|
+
const isolation = opts.isolation ?? "shared";
|
|
98
|
+
const networkAccess = opts.networkAccess ?? "unknown";
|
|
99
|
+
const policy = opts.policy ? normalizePolicy(opts.policy, { baseDir: cwd }) : undefined;
|
|
87
100
|
const repoBefore = snapshotRepo(cwd);
|
|
88
101
|
|
|
89
102
|
// Per-child fixed context overhead: children spawn in `cwd` and load its
|
|
@@ -105,12 +118,15 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
105
118
|
let agentsSpawned = 0;
|
|
106
119
|
let agentsCached = 0;
|
|
107
120
|
let costUsd = 0;
|
|
121
|
+
let reservedCostUsd = 0;
|
|
108
122
|
let currentStage = "";
|
|
109
123
|
let agentSeq = 0;
|
|
110
124
|
let evidenceSeq = 0;
|
|
125
|
+
let policySeq = 0;
|
|
111
126
|
const billingProbed = new Map<HarnessName, BillingProbe>();
|
|
112
127
|
const agentProofs = new Map<string, WorkflowAgentProof>();
|
|
113
128
|
const evidenceRecords: WorkflowEvidenceRecord[] = [];
|
|
129
|
+
const policyDecisions: PolicyDecision[] = [];
|
|
114
130
|
const acceptanceIds = new Set(meta.acceptance.map((criterion) => criterion.id));
|
|
115
131
|
|
|
116
132
|
const journal = (event: string, data: Record<string, unknown>): void => {
|
|
@@ -143,7 +159,98 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
143
159
|
waiters.shift()?.();
|
|
144
160
|
};
|
|
145
161
|
|
|
162
|
+
// Policy checks that affect the shared cost reservation ledger serialize.
|
|
163
|
+
// Spawns still run concurrently after authorization; only the last-moment
|
|
164
|
+
// check-and-reserve section is exclusive.
|
|
165
|
+
let policyGate = Promise.resolve();
|
|
166
|
+
const withDispatchPolicyGate = async <T>(work: () => Promise<T>): Promise<T> => {
|
|
167
|
+
const previous = policyGate;
|
|
168
|
+
let open!: () => void;
|
|
169
|
+
policyGate = new Promise<void>((resolveGate) => {
|
|
170
|
+
open = resolveGate;
|
|
171
|
+
});
|
|
172
|
+
await previous;
|
|
173
|
+
try {
|
|
174
|
+
return await work();
|
|
175
|
+
} finally {
|
|
176
|
+
open();
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const authorizePolicyRequest = async (rawRequest: PolicyRequest): Promise<PolicyDecision> => {
|
|
181
|
+
if (!policy) {
|
|
182
|
+
throw new Error("external mutation authorization requires a host policy");
|
|
183
|
+
}
|
|
184
|
+
if (policyDecisions.length >= MAX_POLICY_DECISIONS) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`workflow policy decision cap reached (${MAX_POLICY_DECISIONS}); split the workflow or reduce protected actions`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const request = summarizePolicyRequest(rawRequest);
|
|
190
|
+
const evaluation = evaluatePolicy(policy, request);
|
|
191
|
+
const id = `p${++policySeq}`;
|
|
192
|
+
const checkedAt = new Date().toISOString();
|
|
193
|
+
journal("policy.check", {
|
|
194
|
+
id,
|
|
195
|
+
policy: policy.name,
|
|
196
|
+
policy_sha256: policyDigest(policy),
|
|
197
|
+
request,
|
|
198
|
+
verdict: evaluation.verdict,
|
|
199
|
+
rules: evaluation.rules,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
let verdict: "allow" | "deny" = evaluation.verdict === "allow" ? "allow" : "deny";
|
|
203
|
+
let resolvedBy: PolicyDecision["resolved_by"] = "policy";
|
|
204
|
+
let reason = evaluation.reason;
|
|
205
|
+
if (evaluation.verdict === "ask") {
|
|
206
|
+
resolvedBy = "fail_closed";
|
|
207
|
+
if (!opts.resolvePolicyAsk) {
|
|
208
|
+
reason = `${evaluation.reason}; no host approval resolver is configured`;
|
|
209
|
+
} else {
|
|
210
|
+
try {
|
|
211
|
+
const resolution = await withTimeout(
|
|
212
|
+
Promise.resolve(opts.resolvePolicyAsk(request, evaluation)),
|
|
213
|
+
opts.policyAskTimeoutMs ?? DEFAULT_POLICY_ASK_TIMEOUT_MS,
|
|
214
|
+
);
|
|
215
|
+
if (!resolution || (resolution.verdict !== "allow" && resolution.verdict !== "deny")) {
|
|
216
|
+
reason = `${evaluation.reason}; host returned an invalid approval resolution`;
|
|
217
|
+
} else {
|
|
218
|
+
verdict = resolution.verdict;
|
|
219
|
+
resolvedBy = "host";
|
|
220
|
+
reason = boundedPolicyReason(resolution.reason ?? evaluation.reason);
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
reason = `${evaluation.reason}; approval failed closed: ${(error as Error).message}`;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const decision: PolicyDecision = {
|
|
229
|
+
id,
|
|
230
|
+
checked_at: checkedAt,
|
|
231
|
+
policy: policy.name,
|
|
232
|
+
phase: request.phase,
|
|
233
|
+
initial_verdict: evaluation.verdict,
|
|
234
|
+
verdict,
|
|
235
|
+
resolved_by: resolvedBy,
|
|
236
|
+
reason: boundedPolicyReason(reason),
|
|
237
|
+
rule_codes: evaluation.rules.map((rule) => rule.code),
|
|
238
|
+
request,
|
|
239
|
+
};
|
|
240
|
+
policyDecisions.push(decision);
|
|
241
|
+
journal("policy.resolve", { ...decision });
|
|
242
|
+
if (decision.verdict === "deny") {
|
|
243
|
+
throw new PolicyDeniedError(
|
|
244
|
+
`policy ${JSON.stringify(policy.name)} denied ${request.phase} ${JSON.stringify(request.action)}: ${decision.reason}`,
|
|
245
|
+
decision.id,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return decision;
|
|
249
|
+
};
|
|
250
|
+
|
|
146
251
|
const agent = async (prompt: string, agentOpts: AgentOpts = {}): Promise<unknown> => {
|
|
252
|
+
let reservedForDispatch = 0;
|
|
253
|
+
let spawnCountClaimed = false;
|
|
147
254
|
const harness = agentOpts.harness ?? defaultHarness;
|
|
148
255
|
const spawner = opts.spawners[harness];
|
|
149
256
|
if (!spawner) {
|
|
@@ -189,57 +296,125 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
189
296
|
return cached.value;
|
|
190
297
|
}
|
|
191
298
|
|
|
299
|
+
if (policy) {
|
|
300
|
+
const estimateRequest = summarizePolicyRequest({
|
|
301
|
+
phase: "dispatch",
|
|
302
|
+
action: "spawn agent",
|
|
303
|
+
path: cwd,
|
|
304
|
+
harness,
|
|
305
|
+
model: agentOpts.model,
|
|
306
|
+
effort: agentOpts.effort,
|
|
307
|
+
max_attempts: maxAttempts,
|
|
308
|
+
max_turns: agentOpts.maxTurns ?? DEFAULT_MAX_TURNS,
|
|
309
|
+
timeout_ms: agentOpts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
310
|
+
prompt_bytes: Buffer.byteLength(prompt),
|
|
311
|
+
isolation,
|
|
312
|
+
network_access: networkAccess,
|
|
313
|
+
current_cost_usd: round4(costUsd + reservedCostUsd),
|
|
314
|
+
projected_cost_usd: null,
|
|
315
|
+
});
|
|
316
|
+
let projectedCost: number | null = null;
|
|
317
|
+
if (policy.max_cost_usd !== undefined && opts.estimateDispatchCost) {
|
|
318
|
+
try {
|
|
319
|
+
const candidate = await opts.estimateDispatchCost(estimateRequest);
|
|
320
|
+
if (candidate !== null && (!Number.isFinite(candidate) || candidate < 0)) {
|
|
321
|
+
throw new Error("cost estimator returned a non-finite or negative value");
|
|
322
|
+
}
|
|
323
|
+
projectedCost = candidate;
|
|
324
|
+
} catch (error) {
|
|
325
|
+
journal("policy.cost_estimate_failed", {
|
|
326
|
+
harness,
|
|
327
|
+
model: agentOpts.model ?? null,
|
|
328
|
+
error: boundedPolicyReason((error as Error).message),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
await withDispatchPolicyGate(async () => {
|
|
334
|
+
assertAgentCapacity(agentsSpawned, maxAgents);
|
|
335
|
+
const dispatchRequest = {
|
|
336
|
+
...estimateRequest,
|
|
337
|
+
current_cost_usd: round4(costUsd + reservedCostUsd),
|
|
338
|
+
projected_cost_usd: projectedCost,
|
|
339
|
+
};
|
|
340
|
+
await authorizePolicyRequest(dispatchRequest);
|
|
341
|
+
if (policy.max_cost_usd !== undefined) {
|
|
342
|
+
reservedForDispatch =
|
|
343
|
+
projectedCost ?? Math.max(0, policy.max_cost_usd - dispatchRequest.current_cost_usd);
|
|
344
|
+
reservedCostUsd += reservedForDispatch;
|
|
345
|
+
}
|
|
346
|
+
agentsSpawned++;
|
|
347
|
+
spawnCountClaimed = true;
|
|
348
|
+
});
|
|
349
|
+
} catch (error) {
|
|
350
|
+
agentProof.error = (error as Error).message;
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
} else {
|
|
354
|
+
try {
|
|
355
|
+
assertAgentCapacity(agentsSpawned, maxAgents);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
agentProof.error = (error as Error).message;
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
192
362
|
// Billing safeguard: on a harness's FIRST spawn this run, classify which
|
|
193
363
|
// auth its children will use and refuse the silent-override state (an
|
|
194
364
|
// exported API key shadowing a stored subscription login) unless the
|
|
195
365
|
// caller explicitly opted into API billing. Cached agents never reach
|
|
196
366
|
// this — no spawn, no billing.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (
|
|
367
|
+
try {
|
|
368
|
+
if (!billingProbed.has(harness)) {
|
|
369
|
+
const probe = (opts.probeBilling ?? probeBilling)(harness);
|
|
370
|
+
billingProbed.set(harness, probe);
|
|
371
|
+
journal("billing.probe", {
|
|
372
|
+
harness,
|
|
373
|
+
mode: opts.subscriptionOnly ? "subscription" : probe.mode,
|
|
374
|
+
api_key_source: probe.apiKeySource,
|
|
375
|
+
login: probe.login,
|
|
376
|
+
subscription_only: Boolean(opts.subscriptionOnly),
|
|
377
|
+
});
|
|
378
|
+
if (opts.subscriptionOnly) {
|
|
379
|
+
if (probe.login === "absent") {
|
|
380
|
+
throw new Error(
|
|
381
|
+
`subscription-only: no stored login detected for ${harness}; ` +
|
|
382
|
+
`log the harness CLI in (or drop --subscription-only for a key-only host)`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
log(`[billing] ${harness}: subscription-only (API-key vars scrubbed from child env)`);
|
|
386
|
+
} else if (probe.mode === "api-key-override" && !opts.allowApiBilling) {
|
|
209
387
|
throw new Error(
|
|
210
|
-
|
|
211
|
-
`
|
|
388
|
+
`${probe.apiKeySource} is set AND a stored ${harness} login exists — the key silently ` +
|
|
389
|
+
`overrides your subscription auth, so children would bill per-token API rates. ` +
|
|
390
|
+
`Either unset ${probe.apiKeySource}, run with --subscription-only to scrub it from ` +
|
|
391
|
+
`child envs, or pass --allow-api-billing if API billing is intended`,
|
|
392
|
+
);
|
|
393
|
+
} else if (probe.mode === "api-key") {
|
|
394
|
+
log(
|
|
395
|
+
`[billing] ${harness}: API-key billing (${probe.apiKeySource}; no stored login detected) — ` +
|
|
396
|
+
`children bill per-token rates`,
|
|
397
|
+
);
|
|
398
|
+
} else if (probe.mode === "api-key-override") {
|
|
399
|
+
log(
|
|
400
|
+
`[billing] ${harness}: API-key billing (--allow-api-billing; key overrides stored login)`,
|
|
212
401
|
);
|
|
402
|
+
} else {
|
|
403
|
+
log(`[billing] ${harness}: subscription login`);
|
|
213
404
|
}
|
|
214
|
-
log(`[billing] ${harness}: subscription-only (API-key vars scrubbed from child env)`);
|
|
215
|
-
} else if (probe.mode === "api-key-override" && !opts.allowApiBilling) {
|
|
216
|
-
throw new Error(
|
|
217
|
-
`${probe.apiKeySource} is set AND a stored ${harness} login exists — the key silently ` +
|
|
218
|
-
`overrides your subscription auth, so children would bill per-token API rates. ` +
|
|
219
|
-
`Either unset ${probe.apiKeySource}, run with --subscription-only to scrub it from ` +
|
|
220
|
-
`child envs, or pass --allow-api-billing if API billing is intended`,
|
|
221
|
-
);
|
|
222
|
-
} else if (probe.mode === "api-key") {
|
|
223
|
-
log(
|
|
224
|
-
`[billing] ${harness}: API-key billing (${probe.apiKeySource}; no stored login detected) — ` +
|
|
225
|
-
`children bill per-token rates`,
|
|
226
|
-
);
|
|
227
|
-
} else if (probe.mode === "api-key-override") {
|
|
228
|
-
log(
|
|
229
|
-
`[billing] ${harness}: API-key billing (--allow-api-billing; key overrides stored login)`,
|
|
230
|
-
);
|
|
231
|
-
} else {
|
|
232
|
-
log(`[billing] ${harness}: subscription login`);
|
|
233
405
|
}
|
|
406
|
+
} catch (error) {
|
|
407
|
+
reservedCostUsd = Math.max(0, reservedCostUsd - reservedForDispatch);
|
|
408
|
+
reservedForDispatch = 0;
|
|
409
|
+
if (spawnCountClaimed) {
|
|
410
|
+
agentsSpawned--;
|
|
411
|
+
spawnCountClaimed = false;
|
|
412
|
+
}
|
|
413
|
+
agentProof.error = (error as Error).message;
|
|
414
|
+
throw error;
|
|
234
415
|
}
|
|
235
416
|
|
|
236
|
-
if (
|
|
237
|
-
agentProof.error = `workflow agent cap reached (${maxAgents})`;
|
|
238
|
-
throw new Error(
|
|
239
|
-
`workflow agent cap reached (${maxAgents}); raise --max-agents deliberately if the fan-out is intended`,
|
|
240
|
-
);
|
|
241
|
-
}
|
|
242
|
-
agentsSpawned++;
|
|
417
|
+
if (!spawnCountClaimed) agentsSpawned++;
|
|
243
418
|
|
|
244
419
|
await acquire();
|
|
245
420
|
try {
|
|
@@ -341,6 +516,7 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
341
516
|
agentProof.error ??= (error as Error).message;
|
|
342
517
|
throw error;
|
|
343
518
|
} finally {
|
|
519
|
+
reservedCostUsd = Math.max(0, reservedCostUsd - reservedForDispatch);
|
|
344
520
|
release();
|
|
345
521
|
}
|
|
346
522
|
};
|
|
@@ -378,7 +554,24 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
378
554
|
return record.id;
|
|
379
555
|
};
|
|
380
556
|
|
|
381
|
-
const
|
|
557
|
+
const authorize = async (input: ExternalMutationRequest): Promise<PolicyDecision> => {
|
|
558
|
+
const network =
|
|
559
|
+
input.network === true || input.service !== undefined || input.target !== undefined
|
|
560
|
+
? "enabled"
|
|
561
|
+
: "disabled";
|
|
562
|
+
return authorizePolicyRequest({
|
|
563
|
+
phase: "external_mutation",
|
|
564
|
+
action: input.action,
|
|
565
|
+
path: input.path ? resolve(cwd, input.path) : undefined,
|
|
566
|
+
isolation,
|
|
567
|
+
network_access: network,
|
|
568
|
+
service: input.service,
|
|
569
|
+
target: input.target,
|
|
570
|
+
current_cost_usd: round4(costUsd),
|
|
571
|
+
});
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const ctx: WorkflowContext = { agent, parallel, stage, log, evidence, authorize };
|
|
382
575
|
|
|
383
576
|
const t0 = Date.now();
|
|
384
577
|
const startedAt = new Date(t0).toISOString();
|
|
@@ -389,6 +582,9 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
389
582
|
acceptance: meta.acceptance,
|
|
390
583
|
max_agents: maxAgents,
|
|
391
584
|
concurrency,
|
|
585
|
+
policy: policy ? { name: policy.name, sha256: policyDigest(policy) } : null,
|
|
586
|
+
isolation,
|
|
587
|
+
network_access: networkAccess,
|
|
392
588
|
});
|
|
393
589
|
try {
|
|
394
590
|
const result = await mod.default(ctx);
|
|
@@ -416,6 +612,9 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
416
612
|
agents: Array.from(agentProofs.values()),
|
|
417
613
|
evidence: evidenceRecords,
|
|
418
614
|
harnessEvidence: opts.harnessEvidence,
|
|
615
|
+
policy: policy
|
|
616
|
+
? { config: policy, decisions: policyDecisions, isolation, networkAccess }
|
|
617
|
+
: undefined,
|
|
419
618
|
result,
|
|
420
619
|
});
|
|
421
620
|
writeWorkflowProof(proofPath, proof);
|
|
@@ -443,6 +642,7 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
443
642
|
harness: p.harness,
|
|
444
643
|
mode: opts.subscriptionOnly ? "subscription" : p.mode,
|
|
445
644
|
})),
|
|
645
|
+
policy: proof.policy?.summary,
|
|
446
646
|
};
|
|
447
647
|
return report;
|
|
448
648
|
} catch (err) {
|
|
@@ -471,6 +671,9 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
471
671
|
agents: Array.from(agentProofs.values()),
|
|
472
672
|
evidence: evidenceRecords,
|
|
473
673
|
harnessEvidence: opts.harnessEvidence,
|
|
674
|
+
policy: policy
|
|
675
|
+
? { config: policy, decisions: policyDecisions, isolation, networkAccess }
|
|
676
|
+
: undefined,
|
|
474
677
|
error: (err as Error).message,
|
|
475
678
|
});
|
|
476
679
|
writeWorkflowProof(proofPath, proof);
|
|
@@ -558,3 +761,42 @@ function loadResumeCache(
|
|
|
558
761
|
function round4(n: number): number {
|
|
559
762
|
return Math.round(n * 10_000) / 10_000;
|
|
560
763
|
}
|
|
764
|
+
|
|
765
|
+
function assertAgentCapacity(agentsSpawned: number, maxAgents: number): void {
|
|
766
|
+
if (agentsSpawned >= maxAgents) {
|
|
767
|
+
throw new Error(
|
|
768
|
+
`workflow agent cap reached (${maxAgents}); raise --max-agents deliberately if the fan-out is intended`,
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function boundedPolicyReason(value: string): string {
|
|
774
|
+
const normalized = Array.from(value, (character) => {
|
|
775
|
+
const code = character.charCodeAt(0);
|
|
776
|
+
return code < 32 || code === 127 ? " " : character;
|
|
777
|
+
})
|
|
778
|
+
.join("")
|
|
779
|
+
.replace(/\s+/g, " ")
|
|
780
|
+
.trim();
|
|
781
|
+
return normalized.length > 2_000 ? `${normalized.slice(0, 1_999)}…` : normalized;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
785
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
786
|
+
throw new Error("policyAskTimeoutMs must be a positive finite number");
|
|
787
|
+
}
|
|
788
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
789
|
+
try {
|
|
790
|
+
return await Promise.race([
|
|
791
|
+
promise,
|
|
792
|
+
new Promise<never>((_, reject) => {
|
|
793
|
+
timer = setTimeout(
|
|
794
|
+
() => reject(new Error(`approval timed out after ${timeoutMs}ms`)),
|
|
795
|
+
timeoutMs,
|
|
796
|
+
);
|
|
797
|
+
}),
|
|
798
|
+
]);
|
|
799
|
+
} finally {
|
|
800
|
+
if (timer) clearTimeout(timer);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
@@ -11,6 +11,13 @@ import {
|
|
|
11
11
|
} from "node:fs";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
13
|
import type { RepoSnapshot } from "../context/index.ts";
|
|
14
|
+
import type {
|
|
15
|
+
NormalizedPolicy,
|
|
16
|
+
PolicyDecision,
|
|
17
|
+
PolicyIsolation,
|
|
18
|
+
PolicyNetworkAccess,
|
|
19
|
+
} from "../policy/index.ts";
|
|
20
|
+
import { policyDigest } from "../policy/index.ts";
|
|
14
21
|
import type {
|
|
15
22
|
AcceptanceCriterion,
|
|
16
23
|
AcceptanceResult,
|
|
@@ -22,6 +29,7 @@ import type {
|
|
|
22
29
|
WorkflowEvidenceInput,
|
|
23
30
|
WorkflowEvidenceRecord,
|
|
24
31
|
WorkflowMeta,
|
|
32
|
+
WorkflowPolicyProof,
|
|
25
33
|
WorkflowProof,
|
|
26
34
|
WorkflowProofUnknown,
|
|
27
35
|
WorkflowRepoEvidence,
|
|
@@ -61,6 +69,12 @@ export interface BuildWorkflowProofInput {
|
|
|
61
69
|
agents: WorkflowAgentProof[];
|
|
62
70
|
evidence: WorkflowEvidenceRecord[];
|
|
63
71
|
harnessEvidence?: Readonly<Record<string, HarnessEvidenceCapability | undefined>>;
|
|
72
|
+
policy?: {
|
|
73
|
+
config: Readonly<NormalizedPolicy>;
|
|
74
|
+
decisions: readonly PolicyDecision[];
|
|
75
|
+
isolation: PolicyIsolation;
|
|
76
|
+
networkAccess: PolicyNetworkAccess;
|
|
77
|
+
};
|
|
64
78
|
result?: unknown;
|
|
65
79
|
error?: string;
|
|
66
80
|
}
|
|
@@ -211,6 +225,7 @@ export function buildWorkflowProof(input: BuildWorkflowProofInput): WorkflowProo
|
|
|
211
225
|
acceptance,
|
|
212
226
|
agents,
|
|
213
227
|
evidence: input.evidence,
|
|
228
|
+
policy: input.policy ? buildPolicyProof(input.policy) : undefined,
|
|
214
229
|
repository,
|
|
215
230
|
harnesses,
|
|
216
231
|
unknowns,
|
|
@@ -224,6 +239,36 @@ export function buildWorkflowProof(input: BuildWorkflowProofInput): WorkflowProo
|
|
|
224
239
|
};
|
|
225
240
|
}
|
|
226
241
|
|
|
242
|
+
function buildPolicyProof(
|
|
243
|
+
input: NonNullable<BuildWorkflowProofInput["policy"]>,
|
|
244
|
+
): WorkflowPolicyProof {
|
|
245
|
+
const decisions = input.decisions.map((decision) => ({
|
|
246
|
+
...decision,
|
|
247
|
+
reason: clipped(decision.reason, MAX_SUMMARY_CHARS),
|
|
248
|
+
request: {
|
|
249
|
+
...decision.request,
|
|
250
|
+
action: clipped(decision.request.action, MAX_REF_CHARS),
|
|
251
|
+
path: clippedOptional(decision.request.path, MAX_REF_CHARS),
|
|
252
|
+
target: clippedOptional(decision.request.target, MAX_REF_CHARS),
|
|
253
|
+
},
|
|
254
|
+
}));
|
|
255
|
+
return {
|
|
256
|
+
schema_version: input.config.schema_version,
|
|
257
|
+
name: input.config.name,
|
|
258
|
+
sha256: policyDigest(input.config),
|
|
259
|
+
isolation: input.isolation,
|
|
260
|
+
network_access: input.networkAccess,
|
|
261
|
+
config: input.config as NormalizedPolicy,
|
|
262
|
+
decisions,
|
|
263
|
+
summary: {
|
|
264
|
+
allowed: decisions.filter((decision) => decision.verdict === "allow").length,
|
|
265
|
+
denied: decisions.filter((decision) => decision.verdict === "deny").length,
|
|
266
|
+
asked: decisions.filter((decision) => decision.initial_verdict === "ask").length,
|
|
267
|
+
total: decisions.length,
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
227
272
|
export function writeWorkflowProof(path: string, proof: WorkflowProof): void {
|
|
228
273
|
const body = `${JSON.stringify(proof, null, 2)}\n`;
|
|
229
274
|
const bytes = Buffer.byteLength(body);
|
|
@@ -278,6 +323,12 @@ export function renderWorkflowProof(proof: WorkflowProof): string {
|
|
|
278
323
|
lines.push(` ${mark} ${criterion.id}: ${criterion.statement}${refs}`);
|
|
279
324
|
}
|
|
280
325
|
lines.push(`evidence: ${proof.evidence.length} record(s); agents: ${proof.agents.length}`);
|
|
326
|
+
if (proof.policy) {
|
|
327
|
+
lines.push(
|
|
328
|
+
`policy: ${proof.policy.name}; ${proof.policy.summary.allowed} allowed, ` +
|
|
329
|
+
`${proof.policy.summary.denied} denied, ${proof.policy.summary.asked} asked`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
281
332
|
const repo = proof.repository;
|
|
282
333
|
const drift = repo.drift;
|
|
283
334
|
lines.push(
|
|
@@ -7,6 +7,16 @@
|
|
|
7
7
|
* Design record: decision 0015 (portable coordination-aware workflows).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import type {
|
|
11
|
+
DispatchCostEstimator,
|
|
12
|
+
ExternalMutationRequest,
|
|
13
|
+
NormalizedPolicy,
|
|
14
|
+
PolicyAskResolver,
|
|
15
|
+
PolicyDecision,
|
|
16
|
+
PolicyIsolation,
|
|
17
|
+
PolicyNetworkAccess,
|
|
18
|
+
PolicySpec,
|
|
19
|
+
} from "../policy/index.ts";
|
|
10
20
|
import type { BillingMode, BillingProber } from "./billing.ts";
|
|
11
21
|
|
|
12
22
|
export const WORKFLOW_PROOF_SCHEMA_VERSION = 1 as const;
|
|
@@ -151,6 +161,7 @@ export interface WorkflowProof {
|
|
|
151
161
|
};
|
|
152
162
|
agents: WorkflowAgentProof[];
|
|
153
163
|
evidence: WorkflowEvidenceRecord[];
|
|
164
|
+
policy?: WorkflowPolicyProof;
|
|
154
165
|
repository: WorkflowRepoEvidence;
|
|
155
166
|
harnesses: HarnessEvidenceCoverage[];
|
|
156
167
|
unknowns: WorkflowProofUnknown[];
|
|
@@ -163,6 +174,22 @@ export interface WorkflowProof {
|
|
|
163
174
|
};
|
|
164
175
|
}
|
|
165
176
|
|
|
177
|
+
export interface WorkflowPolicyProof {
|
|
178
|
+
schema_version: 1;
|
|
179
|
+
name: string;
|
|
180
|
+
sha256: string;
|
|
181
|
+
isolation: PolicyIsolation;
|
|
182
|
+
network_access: PolicyNetworkAccess;
|
|
183
|
+
config: NormalizedPolicy;
|
|
184
|
+
decisions: PolicyDecision[];
|
|
185
|
+
summary: {
|
|
186
|
+
allowed: number;
|
|
187
|
+
denied: number;
|
|
188
|
+
asked: number;
|
|
189
|
+
total: number;
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
166
193
|
export interface HarnessEvidenceCapability {
|
|
167
194
|
toolEvidence: HarnessEvidenceCoverage["tool_evidence"];
|
|
168
195
|
}
|
|
@@ -254,6 +281,8 @@ export interface WorkflowContext {
|
|
|
254
281
|
log: (message: string) => void;
|
|
255
282
|
/** Attach a bounded, sourced receipt to the run and optional acceptance criteria. */
|
|
256
283
|
evidence: (input: WorkflowEvidenceInput) => string;
|
|
284
|
+
/** Authorize one host-mediated external mutation before performing it. */
|
|
285
|
+
authorize: (input: ExternalMutationRequest) => Promise<PolicyDecision>;
|
|
257
286
|
}
|
|
258
287
|
|
|
259
288
|
export interface WorkflowMeta {
|
|
@@ -303,6 +332,18 @@ export interface EngineOpts {
|
|
|
303
332
|
/** Capability claims used to state whether adapter-native tool evidence was
|
|
304
333
|
* available. Missing claims remain unknown. */
|
|
305
334
|
harnessEvidence?: Readonly<Record<HarnessName, HarnessEvidenceCapability | undefined>>;
|
|
335
|
+
/** Immutable host policy. Workflow scripts and model prompts cannot replace it. */
|
|
336
|
+
policy?: PolicySpec | NormalizedPolicy;
|
|
337
|
+
/** Host callback for ASK. Missing, invalid, throwing, or timed-out resolution denies. */
|
|
338
|
+
resolvePolicyAsk?: PolicyAskResolver;
|
|
339
|
+
/** Host-owned cost projection. Required to allow budgeted dispatches with known pricing. */
|
|
340
|
+
estimateDispatchCost?: DispatchCostEstimator;
|
|
341
|
+
/** Maximum wait for an ASK resolver (default 60 seconds). */
|
|
342
|
+
policyAskTimeoutMs?: number;
|
|
343
|
+
/** Execution boundary created by the host (default shared). */
|
|
344
|
+
isolation?: PolicyIsolation;
|
|
345
|
+
/** Network state of spawned harness subprocesses (default unknown). */
|
|
346
|
+
networkAccess?: PolicyNetworkAccess;
|
|
306
347
|
}
|
|
307
348
|
|
|
308
349
|
export interface RunReport {
|
|
@@ -324,4 +365,6 @@ export interface RunReport {
|
|
|
324
365
|
contextTokensPerChildEstimate: number;
|
|
325
366
|
/** Billing mode per harness actually used this run (probed on first use). */
|
|
326
367
|
billing: Array<{ harness: HarnessName; mode: BillingMode }>;
|
|
368
|
+
/** Policy verdict totals when the host supplied a policy. */
|
|
369
|
+
policy?: WorkflowPolicyProof["summary"];
|
|
327
370
|
}
|
|
@@ -285,8 +285,13 @@ export class Browser {
|
|
|
285
285
|
* on screen" signal for LLM iteration loops. For richer extraction, use
|
|
286
286
|
* `htmlContent()` and pipe through a readability filter.
|
|
287
287
|
*/
|
|
288
|
-
async textSnapshot(): Promise<string> {
|
|
288
|
+
async textSnapshot(selector?: string): Promise<string> {
|
|
289
289
|
const page = this.currentPage;
|
|
290
|
+
if (selector) {
|
|
291
|
+
const el = await page.$(selector);
|
|
292
|
+
if (!el) throw new Error(`Selector matched nothing: ${selector}`);
|
|
293
|
+
return await el.evaluate((node) => (node as HTMLElement).innerText ?? "");
|
|
294
|
+
}
|
|
290
295
|
return await page.evaluate(() => document.body?.innerText ?? "");
|
|
291
296
|
}
|
|
292
297
|
|