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.
Files changed (57) 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/browse.js +1 -3
  5. package/dist/commands/eml.d.ts.map +1 -1
  6. package/dist/commands/eml.js +11 -50
  7. package/dist/commands/policy.d.ts +6 -0
  8. package/dist/commands/policy.d.ts.map +1 -0
  9. package/dist/commands/policy.js +42 -0
  10. package/dist/commands/workflow.d.ts.map +1 -1
  11. package/dist/commands/workflow.js +18 -1
  12. package/dist/core/agents/coord-client.d.ts +7 -0
  13. package/dist/core/agents/coord-client.d.ts.map +1 -1
  14. package/dist/core/agents/coord-client.js +27 -1
  15. package/dist/core/policy/evaluate.d.ts +14 -0
  16. package/dist/core/policy/evaluate.d.ts.map +1 -0
  17. package/dist/core/policy/evaluate.js +238 -0
  18. package/dist/core/policy/file.d.ts +3 -0
  19. package/dist/core/policy/file.d.ts.map +1 -0
  20. package/dist/core/policy/file.js +20 -0
  21. package/dist/core/policy/index.d.ts +5 -0
  22. package/dist/core/policy/index.d.ts.map +1 -0
  23. package/dist/core/policy/index.js +3 -0
  24. package/dist/core/policy/types.d.ts +91 -0
  25. package/dist/core/policy/types.d.ts.map +1 -0
  26. package/dist/core/policy/types.js +1 -0
  27. package/dist/core/scratch/index.d.ts.map +1 -1
  28. package/dist/core/scratch/index.js +14 -4
  29. package/dist/core/workflow/engine.d.ts.map +1 -1
  30. package/dist/core/workflow/engine.js +261 -35
  31. package/dist/core/workflow/index.d.ts +1 -1
  32. package/dist/core/workflow/index.d.ts.map +1 -1
  33. package/dist/core/workflow/proof.d.ts +7 -0
  34. package/dist/core/workflow/proof.d.ts.map +1 -1
  35. package/dist/core/workflow/proof.js +33 -0
  36. package/dist/core/workflow/types.d.ts +33 -0
  37. package/dist/core/workflow/types.d.ts.map +1 -1
  38. package/dist/lib/browser/client.d.ts +1 -1
  39. package/dist/lib/browser/client.d.ts.map +1 -1
  40. package/dist/lib/browser/client.js +7 -1
  41. package/package.json +9 -4
  42. package/src/commander.ts +2 -0
  43. package/src/commands/browse.ts +1 -5
  44. package/src/commands/eml.ts +11 -62
  45. package/src/commands/policy.ts +50 -0
  46. package/src/commands/workflow.ts +29 -1
  47. package/src/core/agents/coord-client.ts +28 -1
  48. package/src/core/policy/evaluate.ts +311 -0
  49. package/src/core/policy/file.ts +20 -0
  50. package/src/core/policy/index.ts +26 -0
  51. package/src/core/policy/types.ts +107 -0
  52. package/src/core/scratch/index.ts +20 -4
  53. package/src/core/workflow/engine.ts +283 -41
  54. package/src/core/workflow/index.ts +1 -0
  55. package/src/core/workflow/proof.ts +51 -0
  56. package/src/core/workflow/types.ts +43 -0
  57. 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
- if (!billingProbed.has(harness)) {
198
- const probe = (opts.probeBilling ?? probeBilling)(harness);
199
- billingProbed.set(harness, probe);
200
- journal("billing.probe", {
201
- harness,
202
- mode: opts.subscriptionOnly ? "subscription" : probe.mode,
203
- api_key_source: probe.apiKeySource,
204
- login: probe.login,
205
- subscription_only: Boolean(opts.subscriptionOnly),
206
- });
207
- if (opts.subscriptionOnly) {
208
- if (probe.login === "absent") {
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
- `subscription-only: no stored login detected for ${harness}; ` +
211
- `log the harness CLI in (or drop --subscription-only for a key-only host)`,
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 (agentsSpawned >= maxAgents) {
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 ctx: WorkflowContext = { agent, parallel, stage, log, evidence };
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
+ }
@@ -34,6 +34,7 @@ export type {
34
34
  WorkflowEvidenceRecord,
35
35
  WorkflowMeta,
36
36
  WorkflowModule,
37
+ WorkflowPolicyProof,
37
38
  WorkflowProof,
38
39
  WorkflowProofUnknown,
39
40
  WorkflowRepoEvidence,
@@ -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