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.
- package/dist/commands/harness.d.ts.map +1 -1
- package/dist/commands/harness.js +2 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +64 -0
- package/dist/core/harnesses/attest-projection.d.ts +44 -0
- package/dist/core/harnesses/attest-projection.d.ts.map +1 -0
- package/dist/core/harnesses/attest-projection.js +113 -0
- package/dist/core/harnesses/attest.d.ts +9 -0
- package/dist/core/harnesses/attest.d.ts.map +1 -1
- package/dist/core/harnesses/attest.js +14 -1
- package/dist/core/harnesses/attestation.d.ts +1 -1
- package/dist/core/harnesses/attestation.d.ts.map +1 -1
- package/dist/core/harnesses/attestation.js +7 -1
- package/dist/core/harnesses/bench.d.ts.map +1 -1
- package/dist/core/harnesses/bench.js +13 -0
- package/dist/core/harnesses/profiles.d.ts +7 -0
- package/dist/core/harnesses/profiles.d.ts.map +1 -1
- package/dist/core/harnesses/profiles.js +9 -0
- package/dist/core/harnesses/types.d.ts +14 -1
- package/dist/core/harnesses/types.d.ts.map +1 -1
- package/dist/core/harnesses/types.js +1 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +41 -0
- package/dist/core/workflow/index.d.ts +1 -1
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/index.js +1 -1
- package/dist/core/workflow/proof.d.ts +3 -1
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +1 -0
- package/dist/core/workflow/sandbox-projection.d.ts +57 -0
- package/dist/core/workflow/sandbox-projection.d.ts.map +1 -0
- package/dist/core/workflow/sandbox-projection.js +95 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +6 -1
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
- package/dist/core/workflow/spawn-codex.js +10 -2
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
- package/dist/core/workflow/spawn-cursor.js +6 -1
- package/dist/core/workflow/types.d.ts +51 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/index.d.ts +2 -0
- package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/index.js +1 -0
- package/dist/core/workflow/workspaces/inspect.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/inspect.js +14 -0
- package/dist/core/workflow/workspaces/reclaim.d.ts +50 -0
- package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/reclaim.js +88 -0
- package/dist/lib/tunnel/error-page.d.ts +15 -0
- package/dist/lib/tunnel/error-page.d.ts.map +1 -0
- package/dist/lib/tunnel/error-page.js +98 -0
- package/dist/lib/tunnel/gate.d.ts +1 -12
- package/dist/lib/tunnel/gate.d.ts.map +1 -1
- package/dist/lib/tunnel/gate.js +59 -4
- package/package.json +1 -1
- package/src/commands/harness.ts +6 -0
- package/src/commands/workflow.ts +74 -0
- package/src/core/harnesses/attest-projection.ts +151 -0
- package/src/core/harnesses/attest.ts +23 -1
- package/src/core/harnesses/attestation.ts +7 -1
- package/src/core/harnesses/bench.ts +16 -0
- package/src/core/harnesses/profiles.ts +11 -0
- package/src/core/harnesses/types.ts +15 -0
- package/src/core/workflow/engine.ts +58 -0
- package/src/core/workflow/index.ts +1 -0
- package/src/core/workflow/proof.ts +4 -0
- package/src/core/workflow/sandbox-projection.ts +150 -0
- package/src/core/workflow/spawn-claude.ts +10 -1
- package/src/core/workflow/spawn-codex.ts +17 -2
- package/src/core/workflow/spawn-cursor.ts +10 -1
- package/src/core/workflow/types.ts +54 -0
- package/src/core/workflow/workspaces/index.ts +2 -0
- package/src/core/workflow/workspaces/inspect.ts +15 -0
- package/src/core/workflow/workspaces/reclaim.ts +109 -0
- package/src/lib/tunnel/error-page.ts +114 -0
- 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,
|
|
@@ -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 [];
|