harnery 0.26.0 → 0.27.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 (76) hide show
  1. package/dist/commands/harness.d.ts.map +1 -1
  2. package/dist/commands/harness.js +2 -0
  3. package/dist/commands/workflow.d.ts.map +1 -1
  4. package/dist/commands/workflow.js +64 -0
  5. package/dist/core/harnesses/attest-projection.d.ts +44 -0
  6. package/dist/core/harnesses/attest-projection.d.ts.map +1 -0
  7. package/dist/core/harnesses/attest-projection.js +113 -0
  8. package/dist/core/harnesses/attest.d.ts +9 -0
  9. package/dist/core/harnesses/attest.d.ts.map +1 -1
  10. package/dist/core/harnesses/attest.js +14 -1
  11. package/dist/core/harnesses/attestation.d.ts +1 -1
  12. package/dist/core/harnesses/attestation.d.ts.map +1 -1
  13. package/dist/core/harnesses/attestation.js +7 -1
  14. package/dist/core/harnesses/bench.d.ts.map +1 -1
  15. package/dist/core/harnesses/bench.js +13 -0
  16. package/dist/core/harnesses/profiles.d.ts +7 -0
  17. package/dist/core/harnesses/profiles.d.ts.map +1 -1
  18. package/dist/core/harnesses/profiles.js +9 -0
  19. package/dist/core/harnesses/types.d.ts +14 -1
  20. package/dist/core/harnesses/types.d.ts.map +1 -1
  21. package/dist/core/harnesses/types.js +1 -0
  22. package/dist/core/workflow/engine.d.ts.map +1 -1
  23. package/dist/core/workflow/engine.js +41 -0
  24. package/dist/core/workflow/index.d.ts +1 -1
  25. package/dist/core/workflow/index.d.ts.map +1 -1
  26. package/dist/core/workflow/index.js +1 -1
  27. package/dist/core/workflow/proof.d.ts +3 -1
  28. package/dist/core/workflow/proof.d.ts.map +1 -1
  29. package/dist/core/workflow/proof.js +1 -0
  30. package/dist/core/workflow/sandbox-projection.d.ts +57 -0
  31. package/dist/core/workflow/sandbox-projection.d.ts.map +1 -0
  32. package/dist/core/workflow/sandbox-projection.js +95 -0
  33. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  34. package/dist/core/workflow/spawn-claude.js +6 -1
  35. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  36. package/dist/core/workflow/spawn-codex.js +10 -2
  37. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  38. package/dist/core/workflow/spawn-cursor.js +6 -1
  39. package/dist/core/workflow/types.d.ts +51 -0
  40. package/dist/core/workflow/types.d.ts.map +1 -1
  41. package/dist/core/workflow/workspaces/index.d.ts +2 -0
  42. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  43. package/dist/core/workflow/workspaces/index.js +1 -0
  44. package/dist/core/workflow/workspaces/inspect.d.ts.map +1 -1
  45. package/dist/core/workflow/workspaces/inspect.js +14 -0
  46. package/dist/core/workflow/workspaces/reclaim.d.ts +50 -0
  47. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -0
  48. package/dist/core/workflow/workspaces/reclaim.js +88 -0
  49. package/dist/lib/tunnel/error-page.d.ts +15 -0
  50. package/dist/lib/tunnel/error-page.d.ts.map +1 -0
  51. package/dist/lib/tunnel/error-page.js +98 -0
  52. package/dist/lib/tunnel/gate.d.ts +1 -12
  53. package/dist/lib/tunnel/gate.d.ts.map +1 -1
  54. package/dist/lib/tunnel/gate.js +59 -4
  55. package/package.json +1 -1
  56. package/src/commands/harness.ts +6 -0
  57. package/src/commands/workflow.ts +74 -0
  58. package/src/core/harnesses/attest-projection.ts +151 -0
  59. package/src/core/harnesses/attest.ts +23 -1
  60. package/src/core/harnesses/attestation.ts +7 -1
  61. package/src/core/harnesses/bench.ts +16 -0
  62. package/src/core/harnesses/profiles.ts +11 -0
  63. package/src/core/harnesses/types.ts +15 -0
  64. package/src/core/workflow/engine.ts +58 -0
  65. package/src/core/workflow/index.ts +1 -0
  66. package/src/core/workflow/proof.ts +4 -0
  67. package/src/core/workflow/sandbox-projection.ts +150 -0
  68. package/src/core/workflow/spawn-claude.ts +10 -1
  69. package/src/core/workflow/spawn-codex.ts +17 -2
  70. package/src/core/workflow/spawn-cursor.ts +10 -1
  71. package/src/core/workflow/types.ts +54 -0
  72. package/src/core/workflow/workspaces/index.ts +2 -0
  73. package/src/core/workflow/workspaces/inspect.ts +15 -0
  74. package/src/core/workflow/workspaces/reclaim.ts +109 -0
  75. package/src/lib/tunnel/error-page.ts +114 -0
  76. package/src/lib/tunnel/gate.ts +66 -3
@@ -285,6 +285,21 @@ function observeAdapter(
285
285
  planningFailed = true;
286
286
  }
287
287
 
288
+ // Plan a second invocation carrying a filesystem policy. Offline this can only
289
+ // show whether the adapter *renders* the projection, never whether the vendor
290
+ // enforces it; enforcement needs the live probe (ADR 0041). An adapter that
291
+ // declares no projection throws here, which is the correct rendering.
292
+ let projectionRendered = false;
293
+ try {
294
+ const projected = adapter.buildInvocation(
295
+ { ...request, filesystemPolicy: { mode: "read-only" } },
296
+ "/harnery-bench/final.txt",
297
+ ).argv;
298
+ projectionRendered = projected.join(" ") !== argv.join(" ");
299
+ } catch {
300
+ projectionRendered = false;
301
+ }
302
+
288
303
  let normalized: SpawnResult | null = null;
289
304
  try {
290
305
  normalized = adapter.normalizeResult(adapter.fixture.raw);
@@ -336,6 +351,7 @@ function observeAdapter(
336
351
  normalized && "toolEvidence" in normalized ? "supported" : "unsupported",
337
352
  ),
338
353
  policyMapping: NOT_CHECKED,
354
+ filesystemPolicyProjection: fromAdapter(projectionRendered ? "supported" : "unsupported"),
339
355
  interruption: NOT_CHECKED,
340
356
  streaming: NOT_CHECKED,
341
357
  steering: NOT_CHECKED,
@@ -16,6 +16,7 @@ function capabilities(overrides: Partial<HarnessCapabilities>): HarnessCapabilit
16
16
  cost: unsupported(),
17
17
  toolEvidence: unsupported("The final-result adapter does not retain tool events."),
18
18
  policyMapping: unsupported("No ALLOW/DENY/ASK translation at the workflow boundary."),
19
+ filesystemPolicyProjection: unsupported("The adapter declares no sandbox projection."),
19
20
  interruption: partial("Timeout kills the subprocess; no caller-driven interrupt handle."),
20
21
  streaming: unsupported("Workflow children return one normalized final result."),
21
22
  steering: unsupported("One prompt is fixed at subprocess launch."),
@@ -70,8 +71,18 @@ export const BUILTIN_HARNESS_PROFILES = {
70
71
  modelFamily: "gpt",
71
72
  effortValues: ["none", "minimal", "low", "medium", "high", "xhigh"],
72
73
  verified: { date: "2026-07-25", version: "codex-cli 0.144.5" },
74
+ // Verified against codex-cli 0.144.5: `--sandbox <mode>` plus
75
+ // `sandbox_workspace_write.writable_roots`. Without the writable-root entry
76
+ // a workspace-write child still cannot write a repository's .git directory.
77
+ sandboxProjection: {
78
+ modes: { "read-only": "read-only", "workspace-write": "workspace-write" },
79
+ writableRoots: true,
80
+ },
73
81
  capabilities: capabilities({
74
82
  effortSelection: supported('Mapped to `-c model_reasoning_effort="<level>"`.'),
83
+ filesystemPolicyProjection: supported(
84
+ "Mode renders to --sandbox; writable roots to sandbox_workspace_write.writable_roots.",
85
+ ),
75
86
  maxTurns: unsupported("codex exec exposes no turn-ceiling flag."),
76
87
  sessionId: unsupported("--output-last-message carries no session id."),
77
88
  cost: unsupported("The final-message path carries no usage or cost."),
@@ -16,6 +16,7 @@ export const HARNESS_CAPABILITY_DIMENSIONS = [
16
16
  "cost",
17
17
  "toolEvidence",
18
18
  "policyMapping",
19
+ "filesystemPolicyProjection",
19
20
  "interruption",
20
21
  "streaming",
21
22
  "steering",
@@ -38,6 +39,17 @@ export interface CapabilityClaim {
38
39
 
39
40
  export type HarnessCapabilities = Record<HarnessCapabilityDimension, CapabilityClaim>;
40
41
 
42
+ /** What one harness can represent of a filesystem-policy projection
43
+ * (ADR 0039). `null` means the harness does not distinguish that, which is a
44
+ * fact about the harness rather than something to paper over: a projection the
45
+ * adapter would silently drop must be refused instead. */
46
+ export interface HarnessSandboxProjection {
47
+ /** Native representation per canonical mode, or null when unrepresentable. */
48
+ modes: Record<"read-only" | "workspace-write", string | null>;
49
+ /** Whether the harness accepts an explicit writable-root set. */
50
+ writableRoots: boolean;
51
+ }
52
+
41
53
  export interface HarnessProfile {
42
54
  id: HarnessId;
43
55
  displayName: string;
@@ -52,6 +64,9 @@ export interface HarnessProfile {
52
64
  capabilities: HarnessCapabilities;
53
65
  /** The last real vendor CLI contract used to validate this declaration. */
54
66
  verified?: { date: string; version: string };
67
+ /** How this adapter projects host filesystem policy into the vendor's own
68
+ * sandbox (ADR 0039). Absent means it cannot project any of it. */
69
+ sandboxProjection?: HarnessSandboxProjection;
55
70
  }
56
71
 
57
72
  /** Fully planned child invocation. `resultFile` is used by adapters such as
@@ -53,12 +53,15 @@ import {
53
53
  workflowScriptDigest,
54
54
  writeWorkflowRunManifest,
55
55
  } from "./run-state.ts";
56
+ import { assertProjectionWithinWorkspace, resolveGitGrantRoots } from "./sandbox-projection.ts";
56
57
  import { normalizeWorkflowSpecialists, resolveSpecialistAssignment } from "./specialists.ts";
57
58
  import type {
58
59
  AgentOpts,
59
60
  EngineOpts,
61
+ GitAdministrativeGrant,
60
62
  HarnessName,
61
63
  RunReport,
64
+ SpawnFilesystemPolicy,
62
65
  SpawnResult,
63
66
  StageSchema,
64
67
  WorkflowAgentProof,
@@ -67,6 +70,7 @@ import type {
67
70
  WorkflowEvidenceRecord,
68
71
  WorkflowModule,
69
72
  WorkflowProof,
73
+ WorkflowSandboxProjectionEvidence,
70
74
  WorkflowWorkContext,
71
75
  } from "./types.ts";
72
76
  import { WORKFLOW_PROOF_SCHEMA_VERSION } from "./types.ts";
@@ -85,6 +89,21 @@ import type {
85
89
  } from "./workspaces/types.ts";
86
90
  import { isWorkspaceAttestation } from "./workspaces/validate.ts";
87
91
 
92
+ /** Proof record of the filesystem projection a run applied (ADR 0039/0040).
93
+ * Absent when no policy was in force, which reads as "no projection applied"
94
+ * rather than "an empty one". */
95
+ function sandboxProjectionEvidence(
96
+ policy: SpawnFilesystemPolicy | undefined,
97
+ gitWrite: GitAdministrativeGrant,
98
+ ): WorkflowSandboxProjectionEvidence | undefined {
99
+ if (!policy) return undefined;
100
+ return {
101
+ mode: policy.mode,
102
+ writable_roots: [...(policy.writableRoots ?? [])],
103
+ git_grant: gitWrite,
104
+ };
105
+ }
106
+
88
107
  const DEFAULT_MAX_AGENTS = 50;
89
108
  const DEFAULT_CONCURRENCY = 4;
90
109
  const DEFAULT_MAX_ATTEMPTS = 2;
@@ -317,6 +336,10 @@ async function executeWorkflow(
317
336
  evidence: [],
318
337
  harnessEvidence: opts.harnessEvidence,
319
338
  harnessAttestations: opts.harnessAttestations,
339
+ sandboxProjection: sandboxProjectionEvidence(
340
+ opts.filesystemPolicy,
341
+ opts.gitWrite ?? "none",
342
+ ),
320
343
  policy: policy
321
344
  ? {
322
345
  config: policy,
@@ -342,6 +365,38 @@ async function executeWorkflow(
342
365
  }
343
366
  const effectiveIsolation = workspaceFallback?.effective_isolation ?? isolation;
344
367
  const executionCwd = workspaceBinding?.active_root ?? cwd;
368
+ // Validate the projection once, before any child launches, so a policy that
369
+ // would reach outside the provider's validated root fails the run rather than
370
+ // silently widening one child's write access (ADR 0039).
371
+ const requestedPolicy = opts.filesystemPolicy;
372
+ if (requestedPolicy?.writableRoots?.length) {
373
+ if (!workspaceBinding) {
374
+ throw new Error(
375
+ "a filesystem policy with writable roots requires an isolated workspace; none is bound to this run",
376
+ );
377
+ }
378
+ assertProjectionWithinWorkspace(
379
+ "workflow",
380
+ workspaceBinding.writable_root.realpath,
381
+ requestedPolicy.writableRoots,
382
+ );
383
+ }
384
+ // The Git grant is resolved after containment on purpose: caller-supplied
385
+ // roots must stay inside the workspace, while these come from the verified
386
+ // binding and are the one sanctioned way out of it (ADR 0040).
387
+ const gitWrite = opts.gitWrite ?? "none";
388
+ const gitGrantRoots = resolveGitGrantRoots(gitWrite, workspaceBinding);
389
+ if (gitGrantRoots.length > 0 && !requestedPolicy) {
390
+ throw new Error(
391
+ `gitWrite "${gitWrite}" has no effect without a filesystem policy to carry it; set filesystemPolicy or drop the grant`,
392
+ );
393
+ }
394
+ const filesystemPolicy: SpawnFilesystemPolicy | undefined = requestedPolicy
395
+ ? {
396
+ ...requestedPolicy,
397
+ writableRoots: [...(requestedPolicy.writableRoots ?? []), ...gitGrantRoots],
398
+ }
399
+ : undefined;
345
400
  const executionRepoBefore = resumeState?.manifest.repository_before ?? snapshotRepo(executionCwd);
346
401
  if (!resumeState) {
347
402
  writeWorkflowRunManifest({
@@ -841,6 +896,7 @@ async function executeWorkflow(
841
896
  cwd: executionCwd,
842
897
  runId,
843
898
  subscriptionOnly,
899
+ filesystemPolicy,
844
900
  });
845
901
  agentProof.attempts = attempt;
846
902
  agentProof.duration_ms += last.durationMs;
@@ -1056,6 +1112,7 @@ async function executeWorkflow(
1056
1112
  evidence: evidenceRecords,
1057
1113
  harnessEvidence: opts.harnessEvidence,
1058
1114
  harnessAttestations: opts.harnessAttestations,
1115
+ sandboxProjection: sandboxProjectionEvidence(filesystemPolicy, gitWrite),
1059
1116
  policy: policy
1060
1117
  ? {
1061
1118
  config: policy,
@@ -1136,6 +1193,7 @@ async function executeWorkflow(
1136
1193
  evidence: evidenceRecords,
1137
1194
  harnessEvidence: opts.harnessEvidence,
1138
1195
  harnessAttestations: opts.harnessAttestations,
1196
+ sandboxProjection: sandboxProjectionEvidence(filesystemPolicy, gitWrite),
1139
1197
  policy: policy
1140
1198
  ? {
1141
1199
  config: policy,
@@ -154,6 +154,7 @@ export {
154
154
  isWorkspaceLifecycleState,
155
155
  listWorkflowWorkspaceInspections,
156
156
  prepareIntegration,
157
+ prepareReclaim,
157
158
  probeLocalGitWorktreeProvider,
158
159
  readWorkflowWorkspaceStatus,
159
160
  renderWorkflowWorkspaceStatus,
@@ -30,6 +30,7 @@ import type {
30
30
  WorkflowProofUnknown,
31
31
  WorkflowRepoEvidence,
32
32
  WorkflowRepoSnapshot,
33
+ WorkflowSandboxProjectionEvidence,
33
34
  WorkflowWorkContext,
34
35
  } from "./types.ts";
35
36
  import { WORKFLOW_PROOF_SCHEMA_VERSION } from "./types.ts";
@@ -82,6 +83,8 @@ export interface BuildWorkflowProofInput {
82
83
  agents: WorkflowAgentProof[];
83
84
  evidence: WorkflowEvidenceRecord[];
84
85
  harnessEvidence?: Readonly<Record<string, HarnessEvidenceCapability | undefined>>;
86
+ /** Filesystem policy actually projected into children (ADR 0039). */
87
+ sandboxProjection?: WorkflowSandboxProjectionEvidence;
85
88
  /** Live attestations backing each harness's claims (ADR 0038). Injected by
86
89
  * the caller so proof stays free of filesystem lookups. */
87
90
  harnessAttestations?: Readonly<Record<string, HarnessAttestationCitation | undefined>>;
@@ -254,6 +257,7 @@ export function buildWorkflowProof(input: BuildWorkflowProofInput): WorkflowProo
254
257
  ? buildExecutionEvidence(input.status, input.workspaceBinding, input.workspaceAttestation)
255
258
  : input.workspaceFallback,
256
259
  repository,
260
+ ...(input.sandboxProjection ? { sandbox_projection: input.sandboxProjection } : {}),
257
261
  harnesses,
258
262
  unknowns,
259
263
  integrity: {
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Project the host's filesystem policy into a harness's own vendor sandbox
3
+ * (ADR 0039).
4
+ *
5
+ * Harnery decides where a workflow child may write. Until this existed it never
6
+ * told the child, so a child working in a provider-owned Git worktree could
7
+ * edit files and could not commit: the vendor excludes a repository's
8
+ * administrative directory from its writable set by policy, and Harnery had no
9
+ * way to name it as an exception.
10
+ *
11
+ * The one rule that matters here is that a projection an adapter cannot
12
+ * represent is refused before launch. Passing a policy that gets silently
13
+ * dropped would leave an operator believing a child was constrained when it was
14
+ * not, which is worse than refusing and worse than never offering the feature.
15
+ */
16
+
17
+ import type { HarnessSandboxProjection } from "../harnesses/types.ts";
18
+ import type { GitAdministrativeGrant, SpawnFilesystemPolicy } from "./types.ts";
19
+ import type { WorkspaceBinding } from "./workspaces/types.ts";
20
+
21
+ export class SandboxProjectionError extends Error {
22
+ readonly harness: string;
23
+ readonly reason:
24
+ | "mode_unrepresentable"
25
+ | "writable_roots_unrepresentable"
26
+ | "no_projection"
27
+ | "writable_root_escapes_workspace"
28
+ | "git_grant_unavailable";
29
+
30
+ constructor(harness: string, reason: SandboxProjectionError["reason"], message: string) {
31
+ super(message);
32
+ this.name = "SandboxProjectionError";
33
+ this.harness = harness;
34
+ this.reason = reason;
35
+ }
36
+ }
37
+
38
+ export interface ResolvedSandboxProjection {
39
+ /** Vendor-native name for the requested mode. */
40
+ nativeMode: string;
41
+ writableRoots: readonly string[];
42
+ }
43
+
44
+ /**
45
+ * Resolve a requested policy against what the adapter declares it can carry.
46
+ * Throws rather than degrading; see the module note.
47
+ */
48
+ export function resolveSandboxProjection(
49
+ harness: string,
50
+ declaration: HarnessSandboxProjection | undefined,
51
+ policy: SpawnFilesystemPolicy,
52
+ ): ResolvedSandboxProjection {
53
+ if (!declaration) {
54
+ throw new SandboxProjectionError(
55
+ harness,
56
+ "no_projection",
57
+ `${harness} cannot project a filesystem policy into its sandbox; remove the policy or use a harness that can`,
58
+ );
59
+ }
60
+ const nativeMode = declaration.modes[policy.mode];
61
+ if (!nativeMode) {
62
+ throw new SandboxProjectionError(
63
+ harness,
64
+ "mode_unrepresentable",
65
+ `${harness} does not distinguish the "${policy.mode}" filesystem mode, so it cannot be enforced`,
66
+ );
67
+ }
68
+ const writableRoots = policy.writableRoots ?? [];
69
+ if (writableRoots.length > 0 && !declaration.writableRoots) {
70
+ throw new SandboxProjectionError(
71
+ harness,
72
+ "writable_roots_unrepresentable",
73
+ `${harness} does not accept an explicit writable-root set, so ${writableRoots.length} declared path(s) could not be enforced`,
74
+ );
75
+ }
76
+ for (const root of writableRoots) {
77
+ // Relative paths cannot be validated against the provider's roots and are
78
+ // resolved differently by every vendor, so they are refused outright.
79
+ if (typeof root !== "string" || !root.startsWith("/")) {
80
+ throw new SandboxProjectionError(
81
+ harness,
82
+ "writable_roots_unrepresentable",
83
+ `writable root ${JSON.stringify(root)} must be an absolute path`,
84
+ );
85
+ }
86
+ }
87
+ return { nativeMode, writableRoots };
88
+ }
89
+
90
+ /** True when `candidate` is `root` or lies beneath it, comparing whole path
91
+ * segments so `/a/bc` is not treated as inside `/a/b`. */
92
+ function isWithin(root: string, candidate: string): boolean {
93
+ const normalizedRoot = root.endsWith("/") ? root.slice(0, -1) : root;
94
+ return candidate === normalizedRoot || candidate.startsWith(`${normalizedRoot}/`);
95
+ }
96
+
97
+ /**
98
+ * Refuse a projection that would grant write access outside the root the
99
+ * workspace provider already validated (ADR 0039).
100
+ *
101
+ * The renderer alone cannot do this: it never sees the binding. Granting a path
102
+ * the provider never sanctioned would let a projection quietly widen the blast
103
+ * radius of a run that the workspace lifecycle believes it has contained.
104
+ */
105
+ export function assertProjectionWithinWorkspace(
106
+ harness: string,
107
+ allowedRootRealpath: string,
108
+ writableRoots: readonly string[],
109
+ ): void {
110
+ for (const root of writableRoots) {
111
+ if (!isWithin(allowedRootRealpath, root)) {
112
+ throw new SandboxProjectionError(
113
+ harness,
114
+ "writable_root_escapes_workspace",
115
+ `writable root ${JSON.stringify(root)} is outside the workspace root ${JSON.stringify(allowedRootRealpath)} the provider validated`,
116
+ );
117
+ }
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Resolve a named Git administrative grant into concrete writable roots
123
+ * (ADR 0040).
124
+ *
125
+ * These are the only paths a run may write outside its workspace, and the
126
+ * caller never names them: they come from the binding the provider verified.
127
+ * A caller-supplied path is checked by `assertProjectionWithinWorkspace` and can
128
+ * never reach here, so asking for the grant is the whole of the widening.
129
+ *
130
+ * In a linked worktree both halves of the administrative directory live under
131
+ * the source repository, and a commit needs the shared half regardless, so the
132
+ * grant returns both rather than pretending the private half is useful alone.
133
+ */
134
+ export function resolveGitGrantRoots(
135
+ grant: GitAdministrativeGrant,
136
+ binding: WorkspaceBinding | undefined,
137
+ ): readonly string[] {
138
+ if (grant === "none") return [];
139
+ const repository = binding?.repository;
140
+ if (!repository) {
141
+ throw new SandboxProjectionError(
142
+ "workflow",
143
+ "git_grant_unavailable",
144
+ `gitWrite "${grant}" needs a Git repository binding; this run has ${binding ? "a workspace with no repository" : "no isolated workspace"}`,
145
+ );
146
+ }
147
+ // Deduplicated: a full-clone topology reports the same path for both, and a
148
+ // repeated writable root is noise in the rendered argv and in proof.
149
+ return [...new Set([repository.gitdir.realpath, repository.common_dir.realpath])];
150
+ }
@@ -19,10 +19,11 @@
19
19
  */
20
20
 
21
21
  import { exec } from "../../lib/exec.ts";
22
- import { validateHarnessEffort } from "../harnesses/profiles.ts";
22
+ import { builtinHarnessProfile, validateHarnessEffort } from "../harnesses/profiles.ts";
23
23
  import type { HarnessInvocation, HarnessRawResult } from "../harnesses/types.ts";
24
24
  import { buildChildEnv } from "./child-env.ts";
25
25
  import { notFoundError } from "./harnesses.ts";
26
+ import { resolveSandboxProjection } from "./sandbox-projection.ts";
26
27
  import { vendorFailureText } from "./spawn-failure.ts";
27
28
  import type { Spawner, SpawnRequest, SpawnResult } from "./types.ts";
28
29
 
@@ -38,6 +39,14 @@ interface ClaudeEnvelope {
38
39
 
39
40
  export function buildClaudeInvocation(req: SpawnRequest): HarnessInvocation {
40
41
  validateHarnessEffort("claude-code", req.effort);
42
+ if (req.filesystemPolicy) {
43
+ // Declared unrepresentable: refuse rather than drop it silently (ADR 0039).
44
+ resolveSandboxProjection(
45
+ "claude-code",
46
+ builtinHarnessProfile("claude-code")?.sandboxProjection,
47
+ req.filesystemPolicy,
48
+ );
49
+ }
41
50
  const argv = [
42
51
  "claude",
43
52
  "-p",
@@ -20,16 +20,25 @@ import { existsSync, readFileSync, rmSync } from "node:fs";
20
20
  import { tmpdir } from "node:os";
21
21
  import { join } from "node:path";
22
22
  import { exec } from "../../lib/exec.ts";
23
- import { validateHarnessEffort } from "../harnesses/profiles.ts";
23
+ import { builtinHarnessProfile, validateHarnessEffort } from "../harnesses/profiles.ts";
24
24
  import type { HarnessInvocation, HarnessRawResult } from "../harnesses/types.ts";
25
25
  import { buildChildEnv } from "./child-env.ts";
26
26
  import { notFoundError } from "./harnesses.ts";
27
+ import { resolveSandboxProjection } from "./sandbox-projection.ts";
27
28
  import { vendorFailureText } from "./spawn-failure.ts";
28
29
  import type { Spawner, SpawnRequest, SpawnResult } from "./types.ts";
29
30
 
30
31
  export function buildCodexInvocation(req: SpawnRequest, resultFile?: string): HarnessInvocation {
31
32
  validateHarnessEffort("codex", req.effort);
32
33
  if (!resultFile) throw new Error("codex adapter requires a final-message result file");
34
+ // Default stays workspace-write so an unprojected request is unchanged.
35
+ const projection = req.filesystemPolicy
36
+ ? resolveSandboxProjection(
37
+ "codex",
38
+ builtinHarnessProfile("codex")?.sandboxProjection,
39
+ req.filesystemPolicy,
40
+ )
41
+ : undefined;
33
42
  const argv = [
34
43
  "codex",
35
44
  "exec",
@@ -38,8 +47,14 @@ export function buildCodexInvocation(req: SpawnRequest, resultFile?: string): Ha
38
47
  resultFile,
39
48
  "--skip-git-repo-check",
40
49
  "--sandbox",
41
- "workspace-write",
50
+ projection?.nativeMode ?? "workspace-write",
42
51
  ];
52
+ if (projection && projection.writableRoots.length > 0) {
53
+ argv.push(
54
+ "-c",
55
+ `sandbox_workspace_write.writable_roots=${JSON.stringify(projection.writableRoots)}`,
56
+ );
57
+ }
43
58
  if (req.model) argv.push("--model", req.model);
44
59
  if (req.effort) argv.push("-c", `model_reasoning_effort=${JSON.stringify(req.effort)}`);
45
60
  return { argv, resultFile };
@@ -20,10 +20,11 @@
20
20
  */
21
21
 
22
22
  import { exec } from "../../lib/exec.ts";
23
- import { validateHarnessEffort } from "../harnesses/profiles.ts";
23
+ import { builtinHarnessProfile, validateHarnessEffort } from "../harnesses/profiles.ts";
24
24
  import type { HarnessInvocation, HarnessRawResult } from "../harnesses/types.ts";
25
25
  import { buildChildEnv } from "./child-env.ts";
26
26
  import { notFoundError } from "./harnesses.ts";
27
+ import { resolveSandboxProjection } from "./sandbox-projection.ts";
27
28
  import { vendorFailureText } from "./spawn-failure.ts";
28
29
  import type { Spawner, SpawnRequest, SpawnResult } from "./types.ts";
29
30
 
@@ -54,6 +55,14 @@ export function parseCursorOutput(stdout: string): {
54
55
 
55
56
  export function buildCursorInvocation(req: SpawnRequest): HarnessInvocation {
56
57
  validateHarnessEffort("cursor", req.effort);
58
+ if (req.filesystemPolicy) {
59
+ // Declared unrepresentable: refuse rather than drop it silently (ADR 0039).
60
+ resolveSandboxProjection(
61
+ "cursor",
62
+ builtinHarnessProfile("cursor")?.sandboxProjection,
63
+ req.filesystemPolicy,
64
+ );
65
+ }
57
66
  // --trust: headless cursor-agent refuses untrusted workspaces (exit 1,
58
67
  // "Workspace Trust Required"). --force: a print-mode child has no interactive
59
68
  // approval channel, so host-authorized workflow dispatch must let it run
@@ -151,6 +151,13 @@ export interface HarnessEvidenceCoverage {
151
151
  attestation?: HarnessAttestationCitation;
152
152
  }
153
153
 
154
+ export interface WorkflowSandboxProjectionEvidence {
155
+ mode: SpawnFilesystemPolicy["mode"];
156
+ writable_roots: string[];
157
+ /** Which Git administrative grant the run asked for (ADR 0040). */
158
+ git_grant: GitAdministrativeGrant;
159
+ }
160
+
154
161
  export interface WorkflowProofUnknown {
155
162
  code:
156
163
  | "tool_evidence_unavailable"
@@ -190,6 +197,10 @@ export interface WorkflowProof {
190
197
  /** Immutable terminal provider evidence for isolated execution. */
191
198
  execution?: WorkspaceExecutionEvidence;
192
199
  repository: WorkflowRepoEvidence;
200
+ /** Filesystem policy projected into every child's vendor sandbox (ADR 0039).
201
+ * Absent means no projection was applied, which is the default. Present means
202
+ * the run can be audited for exactly what its children could write. */
203
+ sandbox_projection?: WorkflowSandboxProjectionEvidence;
193
204
  harnesses: HarnessEvidenceCoverage[];
194
205
  unknowns: WorkflowProofUnknown[];
195
206
  integrity: {
@@ -313,6 +324,33 @@ export interface SpawnRequest {
313
324
  /** Scrub all API-key vars from the child env so it can only authenticate
314
325
  * via its stored (subscription) login. See billing.ts. */
315
326
  subscriptionOnly?: boolean;
327
+ /** Filesystem policy to project into the child's own vendor sandbox
328
+ * (ADR 0039). Absent leaves the adapter's default invocation untouched, which
329
+ * is what shared-checkout runs use. An adapter that cannot represent the
330
+ * requested projection refuses before launch rather than downgrading. */
331
+ filesystemPolicy?: SpawnFilesystemPolicy;
332
+ }
333
+
334
+ /**
335
+ * Whether a run may write the Git administrative directory (ADR 0040).
336
+ *
337
+ * There is no middle setting, and the reason is a property of Git rather than a
338
+ * design choice. A linked worktree has a private administrative directory and a
339
+ * shared one, but object writes and branch refs live in the shared half, so even
340
+ * `git add` fails without it. A grant that enables a commit is therefore always
341
+ * a grant on the repository every worktree shares, including the operator's own
342
+ * checkout. That is why it has to be asked for by name.
343
+ */
344
+ export type GitAdministrativeGrant = "none" | "shared-repository";
345
+
346
+ /** What the host has decided the child may write. `full-access` is deliberately
347
+ * not a mode: Harnery does not project a no-sandbox state into a vendor CLI. */
348
+ export interface SpawnFilesystemPolicy {
349
+ mode: "read-only" | "workspace-write";
350
+ /** Explicit absolute paths the child may write, declared rather than derived
351
+ * from `cwd`. The path that needs writing (a repository's administrative
352
+ * directory, for instance) is routinely outside the working directory. */
353
+ writableRoots?: readonly string[];
316
354
  }
317
355
 
318
356
  /** One headless-subagent runner. The engine is adapter-agnostic; claude-code
@@ -444,6 +482,22 @@ export interface EngineOpts {
444
482
  /** Live attestations backing each harness's claims (ADR 0038). Read once by
445
483
  * the host and injected, so the engine performs no capability lookups. */
446
484
  harnessAttestations?: Readonly<Record<HarnessName, HarnessAttestationCitation | undefined>>;
485
+ /** Filesystem policy projected into every child's own vendor sandbox
486
+ * (ADR 0039). Validated against the workspace binding before the first spawn,
487
+ * and recorded in proof. Absent leaves every adapter invocation unchanged. */
488
+ filesystemPolicy?: SpawnFilesystemPolicy;
489
+ /**
490
+ * Whether children may write the repository's administrative directory
491
+ * (ADR 0040). Defaults to `"none"`.
492
+ *
493
+ * The caller asks for the grant by name and never supplies the path: the
494
+ * engine resolves it from the workspace binding the provider already
495
+ * verified. That asymmetry is deliberate. `filesystemPolicy.writableRoots` is
496
+ * caller-supplied and must stay inside the workspace, so a caller cannot use
497
+ * it to reach the source repository; this grant can, which is exactly why it
498
+ * is a named capability rather than another path.
499
+ */
500
+ gitWrite?: GitAdministrativeGrant;
447
501
  /** Immutable host policy. Workflow scripts and model prompts cannot replace it. */
448
502
  policy?: PolicySpec | NormalizedPolicy;
449
503
  /** Host callback for ASK. Missing, invalid, throwing, or timed-out resolution denies. */
@@ -35,6 +35,8 @@ export {
35
35
  createLocalGitWorktreeProvider,
36
36
  probe as probeLocalGitWorktreeProvider,
37
37
  } from "./local-git.ts";
38
+ export type { PrepareReclaimInput, ReclaimMode, ReclaimPreparation } from "./reclaim.ts";
39
+ export { prepareReclaim } from "./reclaim.ts";
38
40
  export type {
39
41
  AuthorizedIntegrationPlan,
40
42
  AuthorizedProviderIntegrationInput,
@@ -337,9 +337,24 @@ export function renderWorkflowWorkspaceStatus(status: WorkflowWorkspaceStatus):
337
337
  `cleanup: ${status.cleanup.state}; ${status.cleanup.attempts} attempt`,
338
338
  );
339
339
  if (status.cleanup.reason) lines.push(`cleanup reason: ${status.cleanup.reason}`);
340
+ // A count alone cannot be acted on: an operator deciding whether to salvage or
341
+ // discard has to know what is actually uncommitted, and leaving Harnery to run
342
+ // `git status` is the gap `reclaim` exists to close (ADR 0042). Bounded,
343
+ // because a dirty tree can be arbitrarily large.
344
+ if (status.repository.dirty_paths.length > 0) {
345
+ // Entries arrive in porcelain form (` M path`), so the status code is worth
346
+ // keeping and the leading pad is not.
347
+ const shown = status.repository.dirty_paths
348
+ .slice(0, MAX_LISTED_DIRTY_PATHS)
349
+ .map((path) => path.trim());
350
+ const remainder = status.repository.dirty_paths.length - shown.length;
351
+ lines.push(`dirty paths: ${shown.join(", ")}${remainder > 0 ? ` (+${remainder} more)` : ""}`);
352
+ }
340
353
  return `${lines.join("\n")}\n`;
341
354
  }
342
355
 
356
+ const MAX_LISTED_DIRTY_PATHS = 12;
357
+
343
358
  function readWorkspaceJournal(runDir: string, runId: string): WorkflowJournalProjectionEvent[] {
344
359
  const path = join(runDir, "journal.jsonl");
345
360
  if (!existsSync(path)) return [];