taskflow-core 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +247 -862
- package/dist/agents.d.ts +12 -0
- package/dist/agents.d.ts.map +1 -1
- package/dist/agents.js +29 -1
- package/dist/agents.js.map +1 -1
- package/dist/cwd-bridge.d.ts +61 -0
- package/dist/cwd-bridge.d.ts.map +1 -0
- package/dist/cwd-bridge.js +136 -0
- package/dist/cwd-bridge.js.map +1 -0
- package/dist/detached-runner.js +31 -5
- package/dist/detached-runner.js.map +1 -1
- package/dist/exec/driver.d.ts +1 -0
- package/dist/exec/driver.d.ts.map +1 -1
- package/dist/exec/driver.js +15 -2
- package/dist/exec/driver.js.map +1 -1
- package/dist/exec/step-kinds.d.ts.map +1 -1
- package/dist/exec/step-kinds.js +28 -3
- package/dist/exec/step-kinds.js.map +1 -1
- package/dist/exec/step.d.ts +1 -0
- package/dist/exec/step.d.ts.map +1 -1
- package/dist/exec/step.js +18 -0
- package/dist/exec/step.js.map +1 -1
- package/dist/flowir/compile.d.ts.map +1 -1
- package/dist/flowir/compile.js +13 -0
- package/dist/flowir/compile.js.map +1 -1
- package/dist/host/runner-types.d.ts +16 -0
- package/dist/host/runner-types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/interpolate.d.ts +4 -0
- package/dist/interpolate.d.ts.map +1 -1
- package/dist/interpolate.js +14 -0
- package/dist/interpolate.js.map +1 -1
- package/dist/resources/authority.d.ts +35 -0
- package/dist/resources/authority.d.ts.map +1 -0
- package/dist/resources/authority.js +67 -0
- package/dist/resources/authority.js.map +1 -0
- package/dist/resources/backend.d.ts +302 -0
- package/dist/resources/backend.d.ts.map +1 -0
- package/dist/resources/backend.js +16 -0
- package/dist/resources/backend.js.map +1 -0
- package/dist/resources/baseline.d.ts +116 -0
- package/dist/resources/baseline.d.ts.map +1 -0
- package/dist/resources/baseline.js +447 -0
- package/dist/resources/baseline.js.map +1 -0
- package/dist/resources/canonical-json.d.ts +7 -0
- package/dist/resources/canonical-json.d.ts.map +1 -0
- package/dist/resources/canonical-json.js +75 -0
- package/dist/resources/canonical-json.js.map +1 -0
- package/dist/resources/errors.d.ts +23 -0
- package/dist/resources/errors.d.ts.map +1 -0
- package/dist/resources/errors.js +89 -0
- package/dist/resources/errors.js.map +1 -0
- package/dist/resources/execution.d.ts +90 -0
- package/dist/resources/execution.d.ts.map +1 -0
- package/dist/resources/execution.js +581 -0
- package/dist/resources/execution.js.map +1 -0
- package/dist/resources/index.d.ts +15 -0
- package/dist/resources/index.d.ts.map +1 -0
- package/dist/resources/index.js +15 -0
- package/dist/resources/index.js.map +1 -0
- package/dist/resources/journal.d.ts +138 -0
- package/dist/resources/journal.d.ts.map +1 -0
- package/dist/resources/journal.js +438 -0
- package/dist/resources/journal.js.map +1 -0
- package/dist/resources/leases.d.ts +51 -0
- package/dist/resources/leases.d.ts.map +1 -0
- package/dist/resources/leases.js +354 -0
- package/dist/resources/leases.js.map +1 -0
- package/dist/resources/permits.d.ts +52 -0
- package/dist/resources/permits.d.ts.map +1 -0
- package/dist/resources/permits.js +240 -0
- package/dist/resources/permits.js.map +1 -0
- package/dist/resources/persistence.d.ts +63 -0
- package/dist/resources/persistence.d.ts.map +1 -0
- package/dist/resources/persistence.js +522 -0
- package/dist/resources/persistence.js.map +1 -0
- package/dist/resources/registry.d.ts +56 -0
- package/dist/resources/registry.d.ts.map +1 -0
- package/dist/resources/registry.js +139 -0
- package/dist/resources/registry.js.map +1 -0
- package/dist/resources/resolve.d.ts +49 -0
- package/dist/resources/resolve.d.ts.map +1 -0
- package/dist/resources/resolve.js +309 -0
- package/dist/resources/resolve.js.map +1 -0
- package/dist/resources/sandbox.d.ts +72 -0
- package/dist/resources/sandbox.d.ts.map +1 -0
- package/dist/resources/sandbox.js +952 -0
- package/dist/resources/sandbox.js.map +1 -0
- package/dist/resources/schema.d.ts +195 -0
- package/dist/resources/schema.d.ts.map +1 -0
- package/dist/resources/schema.js +231 -0
- package/dist/resources/schema.js.map +1 -0
- package/dist/resources/types.d.ts +36 -0
- package/dist/resources/types.d.ts.map +1 -0
- package/dist/resources/types.js +75 -0
- package/dist/resources/types.js.map +1 -0
- package/dist/runner-core.d.ts +29 -0
- package/dist/runner-core.d.ts.map +1 -1
- package/dist/runner-core.js +311 -36
- package/dist/runner-core.js.map +1 -1
- package/dist/runtime/phases/script.d.ts.map +1 -1
- package/dist/runtime/phases/script.js +47 -9
- package/dist/runtime/phases/script.js.map +1 -1
- package/dist/runtime.d.ts +39 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +542 -50
- package/dist/runtime.js.map +1 -1
- package/dist/schema.d.ts +68 -5
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +163 -22
- package/dist/schema.js.map +1 -1
- package/dist/store.d.ts +7 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js.map +1 -1
- package/dist/trace.d.ts +3 -0
- package/dist/trace.d.ts.map +1 -1
- package/dist/trace.js.map +1 -1
- package/package.json +3 -2
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
import { createHmac, randomUUID, timingSafeEqual, } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { hostProbeTargetsEqual, isExactHostProbeTarget, isVerifiedSandboxHostApproval, } from "./baseline.js";
|
|
4
|
+
import { canonicalJson, sha256Canonical } from "./canonical-json.js";
|
|
5
|
+
import { isIssuedMutationPermit } from "./permits.js";
|
|
6
|
+
import { isResolvedPathRef } from "./resolve.js";
|
|
7
|
+
import { SandboxPlanError } from "./backend.js";
|
|
8
|
+
const POLICY_TAG = "tfws-policy/v1";
|
|
9
|
+
const POLICY_PREFIX = "tfws-policy:v1:";
|
|
10
|
+
const ENFORCEMENT_TAG = "tfws-enforcement/v1";
|
|
11
|
+
const ENFORCEMENT_PREFIX = "tfws-enforcement:v1:";
|
|
12
|
+
/** Build the path-free portable policy digest specified by the Workspace RFC. */
|
|
13
|
+
export function computeSandboxPolicyDigest(plan, backend) {
|
|
14
|
+
const grants = plan.grants.map(projectGrant).sort(compareProjectedGrants);
|
|
15
|
+
const credentials = plan.credentialRequirements.map(projectCredential).sort(compareProjectedCredentials);
|
|
16
|
+
const writableScopes = grants
|
|
17
|
+
.filter((grant) => grant.access === "read-write")
|
|
18
|
+
.map((grant) => ({
|
|
19
|
+
resourceDomainId: grant.resourceDomainId,
|
|
20
|
+
logicalWorkspaceId: grant.logicalWorkspaceId,
|
|
21
|
+
logicalPrefix: grant.logicalPrefix,
|
|
22
|
+
scopeKind: grant.scopeKind,
|
|
23
|
+
requiresMutationPermit: true,
|
|
24
|
+
}))
|
|
25
|
+
.sort((left, right) => compareStringTuple([left.resourceDomainId, left.logicalWorkspaceId, left.logicalPrefix, left.scopeKind], [right.resourceDomainId, right.logicalWorkspaceId, right.logicalPrefix, right.scopeKind]));
|
|
26
|
+
const payload = {
|
|
27
|
+
schema: POLICY_TAG,
|
|
28
|
+
mode: plan.mode,
|
|
29
|
+
backend: {
|
|
30
|
+
backendId: backend.backendId,
|
|
31
|
+
backendCapabilityVersion: backend.backendCapabilityVersion,
|
|
32
|
+
},
|
|
33
|
+
baseline: {
|
|
34
|
+
policyId: plan.baseline.policyId,
|
|
35
|
+
policyVersion: plan.baseline.policyVersion,
|
|
36
|
+
bodyDigest: plan.baseline.bodyDigest,
|
|
37
|
+
},
|
|
38
|
+
cwd: {
|
|
39
|
+
logicalWorkspaceId: plan.cwd.capability.logicalWorkspaceId,
|
|
40
|
+
logicalPrefix: plan.cwd.capability.logicalPrefix,
|
|
41
|
+
logicalSubpath: plan.cwd.logicalSubpath,
|
|
42
|
+
intent: plan.cwd.intent,
|
|
43
|
+
access: plan.cwd.capability.access,
|
|
44
|
+
},
|
|
45
|
+
grants,
|
|
46
|
+
writableScopes,
|
|
47
|
+
credentialRequirements: credentials,
|
|
48
|
+
};
|
|
49
|
+
return `${POLICY_PREFIX}${sha256Canonical(payload)}`;
|
|
50
|
+
}
|
|
51
|
+
/** Canonical baseline body digest. The digest field itself is excluded; every
|
|
52
|
+
* other policy field, including identity/version, is bound. */
|
|
53
|
+
export function computeHostBaselineBodyDigest(baseline) {
|
|
54
|
+
const { bodyDigest: _ignored, ...body } = baseline;
|
|
55
|
+
return sha256Canonical(body);
|
|
56
|
+
}
|
|
57
|
+
/** Content address the complete advertised backend capability record. */
|
|
58
|
+
export function computeWorkspaceBackendCapabilitiesSha256(capabilities) {
|
|
59
|
+
assertCapabilities(capabilities);
|
|
60
|
+
return sha256Canonical(capabilities);
|
|
61
|
+
}
|
|
62
|
+
/** Attach the computed portable digest without changing the concrete plan inputs. */
|
|
63
|
+
export function createSandboxPolicyPlan(plan, backend) {
|
|
64
|
+
return {
|
|
65
|
+
...plan,
|
|
66
|
+
policyDigest: computeSandboxPolicyDigest(plan, backend),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Process-local policy factory. It negotiates and authenticates plans, but delegates
|
|
71
|
+
* real native preparation to an adapter and therefore never pretends to be a sandbox.
|
|
72
|
+
*/
|
|
73
|
+
export class SandboxPolicyFactory {
|
|
74
|
+
#options;
|
|
75
|
+
#key;
|
|
76
|
+
#baselineBindings = new Map();
|
|
77
|
+
#prepared = new WeakMap();
|
|
78
|
+
#preparedSealed = new WeakSet();
|
|
79
|
+
#sealed = new WeakMap();
|
|
80
|
+
constructor(options) {
|
|
81
|
+
assertCapabilities(options.capabilities);
|
|
82
|
+
if (options.executionTarget !== "agent" && options.executionTarget !== "script") {
|
|
83
|
+
throw invalid("executionTarget must be agent or script");
|
|
84
|
+
}
|
|
85
|
+
const selectedMode = options.capabilities[options.executionTarget];
|
|
86
|
+
if (selectedMode === "native-single-root" || selectedMode === "native-multi-root") {
|
|
87
|
+
if (!isVerifiedSandboxHostApproval(options.sandboxApproval)) {
|
|
88
|
+
throw unsupported("native sandbox construction requires an exact verified host approval");
|
|
89
|
+
}
|
|
90
|
+
const expectedClassification = selectedMode === "native-single-root"
|
|
91
|
+
? "sandboxed-single-root"
|
|
92
|
+
: "sandboxed-multi-root";
|
|
93
|
+
if (!isExactHostProbeTarget(options.liveTarget) ||
|
|
94
|
+
!hostProbeTargetsEqual(options.sandboxApproval.target, options.liveTarget) ||
|
|
95
|
+
options.sandboxApproval.classification !== expectedClassification ||
|
|
96
|
+
options.sandboxApproval.baselineId !== options.hostSupportBaselineId) {
|
|
97
|
+
throw unsupported("verified host approval does not exactly match the live native host target");
|
|
98
|
+
}
|
|
99
|
+
if (options.liveTarget.backendId !== options.capabilities.backendId ||
|
|
100
|
+
options.liveTarget.backendCapabilityVersion !== options.capabilities.backendCapabilityVersion ||
|
|
101
|
+
options.liveTarget.backendCapabilitiesSha256 !== computeWorkspaceBackendCapabilitiesSha256(options.capabilities) ||
|
|
102
|
+
options.liveTarget.baselinePolicyId !== options.capabilities.baselinePolicyId) {
|
|
103
|
+
throw unsupported("live host target does not match the complete selected backend capabilities");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (!(options.key instanceof Uint8Array) || options.key.byteLength < 32) {
|
|
107
|
+
throw invalid("enforcement HMAC key must contain at least 256 bits");
|
|
108
|
+
}
|
|
109
|
+
this.#key = Buffer.from(options.key);
|
|
110
|
+
this.#options = Object.freeze({
|
|
111
|
+
...options,
|
|
112
|
+
capabilities: deepFreeze(structuredClone(options.capabilities)),
|
|
113
|
+
baselineBindings: deepFreeze(structuredClone(options.baselineBindings)),
|
|
114
|
+
key: this.#key,
|
|
115
|
+
});
|
|
116
|
+
assertNonEmpty(options.keyId, "keyId");
|
|
117
|
+
if (options.preparedPlanTtlMs !== undefined &&
|
|
118
|
+
(!Number.isSafeInteger(options.preparedPlanTtlMs) || options.preparedPlanTtlMs <= 0)) {
|
|
119
|
+
throw invalid("preparedPlanTtlMs must be a positive safe integer");
|
|
120
|
+
}
|
|
121
|
+
for (const binding of options.baselineBindings) {
|
|
122
|
+
validateBaseline(binding);
|
|
123
|
+
const computed = computeHostBaselineBodyDigest(binding);
|
|
124
|
+
if (binding.bodyDigest !== computed) {
|
|
125
|
+
throw new SandboxPlanError("TFWS_BASELINE_MISMATCH", "trusted baseline bodyDigest does not match its canonical body");
|
|
126
|
+
}
|
|
127
|
+
const id = baselineKey(binding.policyId, binding.policyVersion);
|
|
128
|
+
const prior = this.#baselineBindings.get(id);
|
|
129
|
+
if (prior !== undefined && canonicalJson(prior) !== canonicalJson(binding)) {
|
|
130
|
+
throw new SandboxPlanError("TFWS_BASELINE_MISMATCH", `baseline ${id} is bound to more than one body digest`);
|
|
131
|
+
}
|
|
132
|
+
this.#baselineBindings.set(id, deepFreeze(structuredClone(binding)));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
capabilities() {
|
|
136
|
+
return structuredClone(this.#options.capabilities);
|
|
137
|
+
}
|
|
138
|
+
async prepareSandbox(plan, owner) {
|
|
139
|
+
// Check the process-local resolver capability before cloning: structured
|
|
140
|
+
// clone deliberately erases the WeakSet brand. Shape-correct handmade,
|
|
141
|
+
// spread, and deserialized cwd objects are not authority.
|
|
142
|
+
if (!isResolvedPathRef(plan.cwd)) {
|
|
143
|
+
throw new SandboxPlanError("TFWS_INVALID_POLICY", "cwd was not issued by the workspace resolver");
|
|
144
|
+
}
|
|
145
|
+
// Snapshot before any asynchronous adapter call so caller mutation cannot alter
|
|
146
|
+
// the policy which was validated/digested.
|
|
147
|
+
const policySnapshot = freezePolicy(plan);
|
|
148
|
+
const ownerSnapshot = freezeOwner(owner);
|
|
149
|
+
validateOwner(ownerSnapshot);
|
|
150
|
+
validatePolicyShape(policySnapshot, this.#now());
|
|
151
|
+
validatePolicyOwnership(policySnapshot, ownerSnapshot);
|
|
152
|
+
this.#validateBaseline(policySnapshot.baseline);
|
|
153
|
+
const expectedDigest = computeSandboxPolicyDigest(policySnapshot, this.#options.capabilities);
|
|
154
|
+
if (policySnapshot.policyDigest !== expectedDigest) {
|
|
155
|
+
throw new SandboxPlanError("TFWS_POLICY_DIGEST_MISMATCH", "portable policy digest does not match the concrete logical policy");
|
|
156
|
+
}
|
|
157
|
+
this.#negotiate(policySnapshot);
|
|
158
|
+
let evidence;
|
|
159
|
+
if (policySnapshot.mode === "sandboxed") {
|
|
160
|
+
if (!this.#options.concreteAdapter) {
|
|
161
|
+
throw unsupported("sandboxed mode requires concrete native preparation");
|
|
162
|
+
}
|
|
163
|
+
evidence = await this.#options.concreteAdapter.prepareConcreteSandbox(policySnapshot, ownerSnapshot);
|
|
164
|
+
validateConcreteEvidence(evidence, policySnapshot, this.#now());
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
if (!this.#options.authorizeResolveOnly?.(ownerSnapshot, policySnapshot)) {
|
|
168
|
+
throw new SandboxPlanError("TFWS_RESOLVE_ONLY_NOT_AUTHORIZED", "resolve-only is a lower guarantee and requires explicit host authorization");
|
|
169
|
+
}
|
|
170
|
+
if (!this.#options.resolveOnlyAdapter) {
|
|
171
|
+
throw unsupported("resolve-only mode requires an explicit path-preparation adapter");
|
|
172
|
+
}
|
|
173
|
+
evidence = await this.#options.resolveOnlyAdapter.prepareResolveOnly(policySnapshot, ownerSnapshot);
|
|
174
|
+
if (evidence.assurance !== "resolve-only-no-sandbox") {
|
|
175
|
+
throw unsupported("resolve-only adapter returned an invalid assurance level");
|
|
176
|
+
}
|
|
177
|
+
assertNonEmpty(evidence.preparationId, "resolve-only preparationId");
|
|
178
|
+
}
|
|
179
|
+
const now = this.#now();
|
|
180
|
+
const expiryCandidates = [
|
|
181
|
+
now.getTime() + (this.#options.preparedPlanTtlMs ?? 30_000),
|
|
182
|
+
parseTimestamp(policySnapshot.cwd.expiresAt, "cwd expiry"),
|
|
183
|
+
];
|
|
184
|
+
if (evidence.assurance === "native-sandbox-prepared") {
|
|
185
|
+
for (const binding of evidence.credentialBindings) {
|
|
186
|
+
expiryCandidates.push(parseTimestamp(binding.expiresAt, "credential binding expiry"));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const expiryMs = Math.min(...expiryCandidates);
|
|
190
|
+
if (expiryMs <= now.getTime())
|
|
191
|
+
throw unsupported("concrete preparation expires before it can be used");
|
|
192
|
+
const expiresAt = new Date(expiryMs).toISOString();
|
|
193
|
+
const prepared = Object.freeze({
|
|
194
|
+
preparedPlanId: randomUUID(),
|
|
195
|
+
backendId: this.#options.capabilities.backendId,
|
|
196
|
+
backendCapabilityVersion: this.#options.capabilities.backendCapabilityVersion,
|
|
197
|
+
owner: ownerSnapshot,
|
|
198
|
+
policy: policySnapshot,
|
|
199
|
+
expiresAt,
|
|
200
|
+
assurance: evidence.assurance,
|
|
201
|
+
});
|
|
202
|
+
this.#prepared.set(prepared, structuredClone(evidence));
|
|
203
|
+
return prepared;
|
|
204
|
+
}
|
|
205
|
+
sealSandbox(prepared, permits) {
|
|
206
|
+
const evidence = this.#prepared.get(prepared);
|
|
207
|
+
if (!evidence) {
|
|
208
|
+
throw new SandboxPlanError("TFWS_FOREIGN_PREPARED_PLAN", "prepared plan was not produced by this factory");
|
|
209
|
+
}
|
|
210
|
+
if (this.#preparedSealed.has(prepared)) {
|
|
211
|
+
throw new SandboxPlanError("TFWS_PLAN_REPLAY", "prepared plan has already been sealed");
|
|
212
|
+
}
|
|
213
|
+
this.#assertNotExpired(prepared.expiresAt, "prepared sandbox plan");
|
|
214
|
+
validatePermitCoverage(prepared, permits, this.#now());
|
|
215
|
+
const permitCopies = permits.map(freezePermit);
|
|
216
|
+
const enforcementDigest = this.#enforcementDigest(prepared, permitCopies, evidence);
|
|
217
|
+
const sealed = Object.freeze({
|
|
218
|
+
prepared,
|
|
219
|
+
mutationPermits: Object.freeze(permitCopies),
|
|
220
|
+
enforcementDigest,
|
|
221
|
+
});
|
|
222
|
+
this.#preparedSealed.add(prepared);
|
|
223
|
+
this.#sealed.set(sealed, "sealed");
|
|
224
|
+
return sealed;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Activate exactly once and keep permit activation/settlement around the actual
|
|
228
|
+
* execution callback. Callers must put native spawn/open inside `execute`.
|
|
229
|
+
*/
|
|
230
|
+
async activateOnce(plan, hooks, execute) {
|
|
231
|
+
const state = this.#sealed.get(plan);
|
|
232
|
+
if (state !== "sealed") {
|
|
233
|
+
throw new SandboxPlanError("TFWS_PLAN_REPLAY", "sandbox plan is foreign, active, or already used");
|
|
234
|
+
}
|
|
235
|
+
this.#assertNotExpired(plan.prepared.expiresAt, "sandbox plan");
|
|
236
|
+
this.#verifyEnforcementDigest(plan);
|
|
237
|
+
if (plan.mutationPermits.length > 0 && !hooks.activateMutationPermits) {
|
|
238
|
+
throw new SandboxPlanError("TFWS_INVALID_MUTATION_PERMIT", "RW activation requires a journal permit activation hook");
|
|
239
|
+
}
|
|
240
|
+
if (plan.mutationPermits.length > 0 && !hooks.settleMutationPermits) {
|
|
241
|
+
throw new SandboxPlanError("TFWS_INVALID_MUTATION_PERMIT", "RW activation requires a journal permit settlement hook");
|
|
242
|
+
}
|
|
243
|
+
this.#sealed.set(plan, "activating");
|
|
244
|
+
try {
|
|
245
|
+
await hooks.activateMutationPermits?.(plan.mutationPermits, plan.prepared.owner);
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
this.#sealed.set(plan, "sealed");
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
this.#sealed.set(plan, "active");
|
|
252
|
+
const activation = Object.freeze({
|
|
253
|
+
plan,
|
|
254
|
+
activatedAt: this.#now().toISOString(),
|
|
255
|
+
});
|
|
256
|
+
try {
|
|
257
|
+
const result = await execute(activation);
|
|
258
|
+
await hooks.settleMutationPermits?.(plan.mutationPermits, plan.prepared.owner, "completed");
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
await hooks.settleMutationPermits?.(plan.mutationPermits, plan.prepared.owner, "failed").catch(() => undefined);
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
this.#sealed.set(plan, "settled");
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
#validateBaseline(baseline) {
|
|
270
|
+
if (baseline.policyId !== this.#options.capabilities.baselinePolicyId) {
|
|
271
|
+
throw new SandboxPlanError("TFWS_BASELINE_MISMATCH", "plan baseline does not match backend baselinePolicyId");
|
|
272
|
+
}
|
|
273
|
+
const expected = this.#baselineBindings.get(baselineKey(baseline.policyId, baseline.policyVersion));
|
|
274
|
+
const computed = computeHostBaselineBodyDigest(baseline);
|
|
275
|
+
if (baseline.bodyDigest !== computed || expected === undefined || canonicalJson(expected) !== canonicalJson(baseline)) {
|
|
276
|
+
throw new SandboxPlanError("TFWS_BASELINE_MISMATCH", "baseline policy/version/body digest is not registered immutably");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
#negotiate(plan) {
|
|
280
|
+
const capabilities = this.#options.capabilities;
|
|
281
|
+
const selectedMode = capabilities[this.#options.executionTarget];
|
|
282
|
+
const features = capabilities.sandboxFeatures;
|
|
283
|
+
if (selectedMode === "native-multi-root") {
|
|
284
|
+
throw unsupported("native multi-root requires per-grant resolver capabilities and is not implemented");
|
|
285
|
+
}
|
|
286
|
+
if (plan.mode === "sandboxed" && selectedMode === "resolve-only") {
|
|
287
|
+
throw unsupported(`${this.#options.executionTarget} backend is resolve-only`);
|
|
288
|
+
}
|
|
289
|
+
if (plan.mode === "resolve-only" && selectedMode !== "resolve-only") {
|
|
290
|
+
throw unsupported("resolve-only must be advertised explicitly; it is not a sandbox fallback");
|
|
291
|
+
}
|
|
292
|
+
if (plan.grants.length > features.maxGrants)
|
|
293
|
+
throw unsupported("grant count exceeds backend limit");
|
|
294
|
+
if (selectedMode === "native-single-root" || selectedMode === "resolve-only") {
|
|
295
|
+
if (plan.grants.length !== 1)
|
|
296
|
+
throw unsupported("single-root execution requires exactly one grant");
|
|
297
|
+
}
|
|
298
|
+
for (const grant of plan.grants) {
|
|
299
|
+
if (!features.scopeKinds.includes(grant.scopeKind)) {
|
|
300
|
+
throw unsupported(`scope kind ${grant.scopeKind} is not representable`);
|
|
301
|
+
}
|
|
302
|
+
if (grant.access === "read-write" && !features.perGrantAccess) {
|
|
303
|
+
throw unsupported("backend cannot enforce requested per-grant read-write access");
|
|
304
|
+
}
|
|
305
|
+
if (grant.access === "read-only" && !features.perGrantAccess) {
|
|
306
|
+
throw unsupported("backend cannot enforce requested per-grant read-only access");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (plan.mode === "sandboxed") {
|
|
310
|
+
if (!features.denyAmbientUserData)
|
|
311
|
+
throw unsupported("ambient user-data denial is unavailable");
|
|
312
|
+
if (!features.exactBaselineMounts)
|
|
313
|
+
throw unsupported("exact baseline mounts are unavailable");
|
|
314
|
+
if (!features.privateTempPerExecution)
|
|
315
|
+
throw unsupported("private per-execution temp is unavailable");
|
|
316
|
+
if (!features.descendantEnforcement)
|
|
317
|
+
throw unsupported("descendant/tool enforcement is unavailable");
|
|
318
|
+
if (!features.raceFreeFileBroker)
|
|
319
|
+
throw unsupported("race-free file-broker enforcement is unavailable");
|
|
320
|
+
if (!capabilities.brokeredRead || !capabilities.brokeredWrite) {
|
|
321
|
+
throw unsupported("complete brokered read/write enforcement is unavailable");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const network = plan.baseline.network ?? "host-policy";
|
|
325
|
+
if (!features.networkModes.includes(network))
|
|
326
|
+
throw unsupported(`network mode ${network} is unavailable`);
|
|
327
|
+
for (const credential of plan.credentialRequirements) {
|
|
328
|
+
if (!features.credentialModes.includes(credential.delivery.mode)) {
|
|
329
|
+
throw unsupported(`credential mode ${credential.delivery.mode} is unavailable`);
|
|
330
|
+
}
|
|
331
|
+
if (plan.baseline.credentialDelivery.mode === "unavailable" ||
|
|
332
|
+
plan.baseline.credentialDelivery.mode !== credential.delivery.mode) {
|
|
333
|
+
throw unsupported("credential requirement is not provided by the selected baseline");
|
|
334
|
+
}
|
|
335
|
+
if (plan.baseline.credentialDelivery.mode === "opaque-broker" &&
|
|
336
|
+
credential.ttlMs > plan.baseline.credentialDelivery.maxTtlMs) {
|
|
337
|
+
throw unsupported("credential TTL exceeds the host baseline broker limit");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
#enforcementDigest(prepared, permits, evidence) {
|
|
342
|
+
const payload = enforcementPayload(prepared, permits, evidence);
|
|
343
|
+
const mac = createHmac("sha256", this.#key).update(canonicalJson(payload), "utf8").digest("hex");
|
|
344
|
+
return `${ENFORCEMENT_PREFIX}${this.#options.keyId}:${mac}`;
|
|
345
|
+
}
|
|
346
|
+
#verifyEnforcementDigest(plan) {
|
|
347
|
+
const evidence = this.#prepared.get(plan.prepared);
|
|
348
|
+
if (!evidence)
|
|
349
|
+
throw new SandboxPlanError("TFWS_FOREIGN_PREPARED_PLAN", "unknown prepared plan");
|
|
350
|
+
const expected = this.#enforcementDigest(plan.prepared, plan.mutationPermits, evidence);
|
|
351
|
+
const actualBytes = Buffer.from(plan.enforcementDigest, "utf8");
|
|
352
|
+
const expectedBytes = Buffer.from(expected, "utf8");
|
|
353
|
+
if (actualBytes.length !== expectedBytes.length || !timingSafeEqual(actualBytes, expectedBytes)) {
|
|
354
|
+
throw new SandboxPlanError("TFWS_ENFORCEMENT_DIGEST_MISMATCH", "sealed physical plan authentication failed");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
#assertNotExpired(expiresAt, kind) {
|
|
358
|
+
const expiry = parseTimestamp(expiresAt, `${kind} expiry`);
|
|
359
|
+
if (expiry <= this.#now().getTime()) {
|
|
360
|
+
throw new SandboxPlanError("TFWS_PREPARED_PLAN_EXPIRED", `${kind} has expired`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
#now() {
|
|
364
|
+
return this.#options.now?.() ?? new Date();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
function enforcementPayload(prepared, permits, evidence) {
|
|
368
|
+
return {
|
|
369
|
+
schema: ENFORCEMENT_TAG,
|
|
370
|
+
policyDigest: prepared.policy.policyDigest,
|
|
371
|
+
preparedPlanId: prepared.preparedPlanId,
|
|
372
|
+
expiresAt: prepared.expiresAt,
|
|
373
|
+
owner: prepared.owner,
|
|
374
|
+
assurance: prepared.assurance,
|
|
375
|
+
preparationId: evidence.preparationId,
|
|
376
|
+
physicalMappings: prepared.policy.grants.map((grant) => ({
|
|
377
|
+
bindingId: grant.bindingId,
|
|
378
|
+
resourceDomainId: grant.resourceDomainId,
|
|
379
|
+
providerInstanceId: grant.providerInstanceId,
|
|
380
|
+
logicalWorkspaceId: grant.logicalWorkspaceId,
|
|
381
|
+
logicalPrefix: grant.logicalPrefix,
|
|
382
|
+
physicalScopeRoot: grant.physicalScopeRoot,
|
|
383
|
+
scopeKind: grant.scopeKind,
|
|
384
|
+
access: grant.access,
|
|
385
|
+
})).sort(compareCanonical),
|
|
386
|
+
cwdPhysicalPath: prepared.policy.cwd.physicalPath,
|
|
387
|
+
baselineMounts: evidence.assurance === "native-sandbox-prepared"
|
|
388
|
+
? evidence.baselineMounts.map(projectBaselineMount).sort(compareCanonical)
|
|
389
|
+
: [],
|
|
390
|
+
credentialBrokerBindings: evidence.assurance === "native-sandbox-prepared"
|
|
391
|
+
? evidence.credentialBindings.map(projectCredentialBinding).sort(compareCanonical)
|
|
392
|
+
: [],
|
|
393
|
+
mutationPermits: permits.map((permit) => ({
|
|
394
|
+
permitId: permit.permitId,
|
|
395
|
+
intentId: permit.intentId,
|
|
396
|
+
journalEpoch: permit.journalEpoch,
|
|
397
|
+
nonce: permit.nonce,
|
|
398
|
+
owner: permit.owner,
|
|
399
|
+
expiresAt: permit.expiresAt,
|
|
400
|
+
scopes: permit.scopes.map((scope) => ({ ...scope })).sort(compareCanonical),
|
|
401
|
+
})).sort(compareCanonical),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
function validatePolicyShape(plan, now) {
|
|
405
|
+
assertExactKeys(plan, ["mode", "cwd", "grants", "baseline", "credentialRequirements", "policyDigest"], "policy");
|
|
406
|
+
if (plan.mode !== "sandboxed" && plan.mode !== "resolve-only")
|
|
407
|
+
throw invalid("invalid plan mode");
|
|
408
|
+
if (!Array.isArray(plan.grants) || !Array.isArray(plan.credentialRequirements)) {
|
|
409
|
+
throw invalid("grants and credentialRequirements must be arrays");
|
|
410
|
+
}
|
|
411
|
+
validateBaseline(plan.baseline);
|
|
412
|
+
validateResolvedCwd(plan, now);
|
|
413
|
+
for (const grant of plan.grants)
|
|
414
|
+
validateGrant(grant);
|
|
415
|
+
for (const credential of plan.credentialRequirements)
|
|
416
|
+
validateCredential(credential);
|
|
417
|
+
assertDigest(plan.policyDigest, "policyDigest", POLICY_PREFIX);
|
|
418
|
+
const cwdGrant = plan.grants.find((grant) => grant.bindingId === plan.cwd.capability.bindingId &&
|
|
419
|
+
grant.resourceDomainId === plan.cwd.capability.resourceDomainId &&
|
|
420
|
+
grant.providerInstanceId === plan.cwd.capability.providerInstanceId &&
|
|
421
|
+
grant.logicalWorkspaceId === plan.cwd.capability.logicalWorkspaceId &&
|
|
422
|
+
grant.logicalPrefix === plan.cwd.capability.logicalPrefix);
|
|
423
|
+
if (!cwdGrant || cwdGrant.scopeKind !== "directory")
|
|
424
|
+
throw invalid("cwd is not represented by a directory grant");
|
|
425
|
+
if (!accessCovers(cwdGrant.access, plan.cwd.capability.access))
|
|
426
|
+
throw invalid("cwd access exceeds its grant");
|
|
427
|
+
if (cwdGrant.physicalScopeRoot !== plan.cwd.capability.physicalScopeRoot) {
|
|
428
|
+
throw invalid("cwd capability scope does not equal its concrete grant scope");
|
|
429
|
+
}
|
|
430
|
+
if (!lifetimeCovers(cwdGrant.lifetime, plan.cwd.capability.lifetime)) {
|
|
431
|
+
throw invalid("cwd capability lifetime exceeds its concrete grant lifetime");
|
|
432
|
+
}
|
|
433
|
+
if (!physicalContains(cwdGrant.physicalScopeRoot, plan.cwd.physicalPath)) {
|
|
434
|
+
throw invalid("cwd physical path escapes its concrete scope");
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function lifetimeCovers(parent, child) {
|
|
438
|
+
if (parent.scope === "external") {
|
|
439
|
+
if (child.scope !== "external")
|
|
440
|
+
return true;
|
|
441
|
+
return parent.bindingId === child.bindingId &&
|
|
442
|
+
parent.providerInstanceId === child.providerInstanceId;
|
|
443
|
+
}
|
|
444
|
+
if (parent.scope === "run") {
|
|
445
|
+
return (child.scope === "run" && child.runId === parent.runId) ||
|
|
446
|
+
(child.scope === "phase" && child.runId === parent.runId);
|
|
447
|
+
}
|
|
448
|
+
return child.scope === "phase" && canonicalJson(parent) === canonicalJson(child);
|
|
449
|
+
}
|
|
450
|
+
function validatePolicyOwnership(plan, owner) {
|
|
451
|
+
for (const lifetime of [plan.cwd.capability.lifetime, ...plan.grants.map((grant) => grant.lifetime)]) {
|
|
452
|
+
if (lifetime.scope === "phase" &&
|
|
453
|
+
(lifetime.runId !== owner.runId || lifetime.phaseId !== owner.phaseId || lifetime.attemptId !== owner.attemptId)) {
|
|
454
|
+
throw invalid("phase-scoped capability does not belong to the execution owner");
|
|
455
|
+
}
|
|
456
|
+
if (lifetime.scope === "run" && lifetime.runId !== owner.runId) {
|
|
457
|
+
throw invalid("run-scoped capability does not belong to the execution owner");
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
function validateResolvedCwd(plan, now) {
|
|
462
|
+
const cwd = plan.cwd;
|
|
463
|
+
assertExactKeys(cwd, ["resolutionTokenId", "expiresAt", "capability", "logicalSubpath", "physicalPath", "intent"], "cwd");
|
|
464
|
+
assertNonEmpty(cwd.resolutionTokenId, "cwd resolutionTokenId");
|
|
465
|
+
parseTimestamp(cwd.expiresAt, "cwd expiry");
|
|
466
|
+
if (parseTimestamp(cwd.expiresAt, "cwd expiry") <= now.getTime())
|
|
467
|
+
throw invalid("cwd resolution has expired");
|
|
468
|
+
if (cwd.intent !== "existing-directory")
|
|
469
|
+
throw invalid("cwd intent must be existing-directory");
|
|
470
|
+
assertPhysicalPath(cwd.physicalPath, "cwd physicalPath");
|
|
471
|
+
validateCapability(cwd.capability);
|
|
472
|
+
}
|
|
473
|
+
function validateCapability(capability) {
|
|
474
|
+
assertExactKeys(capability, [
|
|
475
|
+
"bindingId", "resourceDomainId", "providerInstanceId", "logicalWorkspaceId",
|
|
476
|
+
"logicalPrefix", "physicalScopeRoot", "access", "version", "lifetime",
|
|
477
|
+
], "cwd capability");
|
|
478
|
+
for (const [name, value] of Object.entries({
|
|
479
|
+
bindingId: capability.bindingId,
|
|
480
|
+
resourceDomainId: capability.resourceDomainId,
|
|
481
|
+
providerInstanceId: capability.providerInstanceId,
|
|
482
|
+
logicalWorkspaceId: capability.logicalWorkspaceId,
|
|
483
|
+
}))
|
|
484
|
+
assertNonEmpty(value, `cwd capability ${name}`);
|
|
485
|
+
validateLogicalPrefix(capability.logicalPrefix);
|
|
486
|
+
assertPhysicalPath(capability.physicalScopeRoot, "cwd capability physicalScopeRoot");
|
|
487
|
+
validateAccess(capability.access);
|
|
488
|
+
assertExactKeys(capability.version, ["identityMode", "contentId", "scopeDigest", "generation", "state"], "resource version");
|
|
489
|
+
if (!Number.isSafeInteger(capability.version.generation) || capability.version.generation < 0) {
|
|
490
|
+
throw invalid("resource version generation must be a non-negative safe integer");
|
|
491
|
+
}
|
|
492
|
+
validateLifetime(capability.lifetime);
|
|
493
|
+
}
|
|
494
|
+
function validateGrant(grant) {
|
|
495
|
+
assertExactKeys(grant, [
|
|
496
|
+
"bindingId", "resourceDomainId", "providerInstanceId", "logicalWorkspaceId",
|
|
497
|
+
"logicalPrefix", "physicalScopeRoot", "scopeKind", "access", "lifetime",
|
|
498
|
+
], "grant");
|
|
499
|
+
for (const [name, value] of Object.entries({
|
|
500
|
+
bindingId: grant.bindingId,
|
|
501
|
+
resourceDomainId: grant.resourceDomainId,
|
|
502
|
+
providerInstanceId: grant.providerInstanceId,
|
|
503
|
+
logicalWorkspaceId: grant.logicalWorkspaceId,
|
|
504
|
+
}))
|
|
505
|
+
assertNonEmpty(value, `grant ${name}`);
|
|
506
|
+
validateLogicalPrefix(grant.logicalPrefix);
|
|
507
|
+
assertPhysicalPath(grant.physicalScopeRoot, "grant physicalScopeRoot");
|
|
508
|
+
if (grant.scopeKind !== "file" && grant.scopeKind !== "directory")
|
|
509
|
+
throw invalid("invalid grant scopeKind");
|
|
510
|
+
validateAccess(grant.access);
|
|
511
|
+
validateLifetime(grant.lifetime);
|
|
512
|
+
}
|
|
513
|
+
function validateBaseline(baseline) {
|
|
514
|
+
assertExactKeys(baseline, [
|
|
515
|
+
"schemaVersion", "policyId", "policyVersion", "bodyDigest", "readableSystemClasses",
|
|
516
|
+
"providerMetadata", "credentialDelivery", "temp", "network",
|
|
517
|
+
], "baseline");
|
|
518
|
+
if (baseline.schemaVersion !== 1)
|
|
519
|
+
throw invalid("baseline schemaVersion must be 1");
|
|
520
|
+
assertNonEmpty(baseline.policyId, "baseline policyId");
|
|
521
|
+
assertNonEmpty(baseline.policyVersion, "baseline policyVersion");
|
|
522
|
+
assertDigest(baseline.bodyDigest, "baseline bodyDigest");
|
|
523
|
+
if (!Array.isArray(baseline.readableSystemClasses) || !Array.isArray(baseline.providerMetadata)) {
|
|
524
|
+
throw invalid("baseline classes and metadata must be arrays");
|
|
525
|
+
}
|
|
526
|
+
if (baseline.temp?.mode !== "private-per-execution" || baseline.temp.access !== "read-write") {
|
|
527
|
+
throw invalid("baseline temp must be private-per-execution read-write");
|
|
528
|
+
}
|
|
529
|
+
if (baseline.network !== undefined && baseline.network !== "host-policy" && baseline.network !== "none") {
|
|
530
|
+
throw invalid("invalid baseline network mode");
|
|
531
|
+
}
|
|
532
|
+
for (const metadata of baseline.providerMetadata) {
|
|
533
|
+
assertExactKeys(metadata, ["metadataId", "kind", "contentDigest", "secretScanPolicyId"], "provider metadata");
|
|
534
|
+
assertNonEmpty(metadata.metadataId, "provider metadataId");
|
|
535
|
+
if (metadata.kind !== "exact-file")
|
|
536
|
+
throw invalid("provider metadata must be exact-file");
|
|
537
|
+
assertDigest(metadata.contentDigest, "provider metadata contentDigest");
|
|
538
|
+
assertNonEmpty(metadata.secretScanPolicyId, "provider metadata secretScanPolicyId");
|
|
539
|
+
}
|
|
540
|
+
const delivery = baseline.credentialDelivery;
|
|
541
|
+
if (delivery.mode === "opaque-broker") {
|
|
542
|
+
assertExactKeys(delivery, ["mode", "brokerId", "maxTtlMs"], "credential delivery");
|
|
543
|
+
assertNonEmpty(delivery.brokerId, "credential brokerId");
|
|
544
|
+
if (!Number.isSafeInteger(delivery.maxTtlMs) || delivery.maxTtlMs <= 0)
|
|
545
|
+
throw invalid("invalid broker maxTtlMs");
|
|
546
|
+
}
|
|
547
|
+
else if (delivery.mode === "isolated-host-process") {
|
|
548
|
+
assertExactKeys(delivery, ["mode", "scrubToolEnvironment", "denyProcessInspection"], "credential delivery");
|
|
549
|
+
if (delivery.scrubToolEnvironment !== true || delivery.denyProcessInspection !== true) {
|
|
550
|
+
throw invalid("isolated host credential delivery requires both isolation controls");
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
else if (delivery.mode !== "unavailable") {
|
|
554
|
+
throw invalid("invalid credential delivery mode");
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
assertExactKeys(delivery, ["mode"], "credential delivery");
|
|
558
|
+
}
|
|
559
|
+
assertExactKeys(baseline.temp, ["mode", "access"], "baseline temp");
|
|
560
|
+
}
|
|
561
|
+
function validateCredential(requirement) {
|
|
562
|
+
assertExactKeys(requirement, [
|
|
563
|
+
"credentialGrantId", "credentialId", "audience", "purpose", "ttlMs", "delivery",
|
|
564
|
+
], "credential requirement");
|
|
565
|
+
assertNonEmpty(requirement.credentialGrantId, "credentialGrantId");
|
|
566
|
+
assertNonEmpty(requirement.credentialId, "credentialId");
|
|
567
|
+
assertNonEmpty(requirement.audience, "credential audience");
|
|
568
|
+
assertNonEmpty(requirement.purpose, "credential purpose");
|
|
569
|
+
if (!Number.isSafeInteger(requirement.ttlMs) || requirement.ttlMs <= 0)
|
|
570
|
+
throw invalid("invalid credential ttlMs");
|
|
571
|
+
if (requirement.delivery.mode === "opaque-broker") {
|
|
572
|
+
assertExactKeys(requirement.delivery, ["mode", "brokerId", "maxTtlMs"], "bound credential delivery");
|
|
573
|
+
assertNonEmpty(requirement.delivery.brokerId, "credential brokerId");
|
|
574
|
+
if (requirement.ttlMs > requirement.delivery.maxTtlMs)
|
|
575
|
+
throw invalid("credential ttl exceeds delivery maximum");
|
|
576
|
+
}
|
|
577
|
+
else if (requirement.delivery.mode !== "isolated-host-process") {
|
|
578
|
+
throw invalid("bound credential cannot use unavailable delivery");
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
assertExactKeys(requirement.delivery, ["mode", "scrubToolEnvironment", "denyProcessInspection"], "bound credential delivery");
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
function validateConcreteEvidence(evidence, plan, now) {
|
|
585
|
+
if (evidence.assurance !== "native-sandbox-prepared") {
|
|
586
|
+
throw unsupported("native adapter did not attest concrete preparation");
|
|
587
|
+
}
|
|
588
|
+
assertNonEmpty(evidence.preparationId, "native preparationId");
|
|
589
|
+
if (!Array.isArray(evidence.baselineMounts) || !Array.isArray(evidence.credentialBindings)) {
|
|
590
|
+
throw unsupported("native preparation omitted concrete baseline or credential evidence");
|
|
591
|
+
}
|
|
592
|
+
const expectedMountIds = new Set([
|
|
593
|
+
...plan.baseline.readableSystemClasses.map((kind) => `system:${kind}`),
|
|
594
|
+
...plan.baseline.providerMetadata.map((metadata) => `provider:${metadata.metadataId}`),
|
|
595
|
+
"temp:execution",
|
|
596
|
+
]);
|
|
597
|
+
const observedMountIds = new Set();
|
|
598
|
+
for (const mount of evidence.baselineMounts) {
|
|
599
|
+
assertNonEmpty(mount.mountId, "baseline mountId");
|
|
600
|
+
assertPhysicalPath(mount.physicalPath, "baseline mount physicalPath");
|
|
601
|
+
validateAccess(mount.access);
|
|
602
|
+
if (observedMountIds.has(mount.mountId))
|
|
603
|
+
throw unsupported("duplicate concrete baseline mount");
|
|
604
|
+
observedMountIds.add(mount.mountId);
|
|
605
|
+
}
|
|
606
|
+
if (expectedMountIds.size !== observedMountIds.size || [...expectedMountIds].some((id) => !observedMountIds.has(id))) {
|
|
607
|
+
throw unsupported("native preparation did not bind the exact baseline mount set");
|
|
608
|
+
}
|
|
609
|
+
const requiredIds = new Set(plan.credentialRequirements.map((item) => item.credentialGrantId));
|
|
610
|
+
const observedIds = new Set();
|
|
611
|
+
for (const binding of evidence.credentialBindings) {
|
|
612
|
+
assertNonEmpty(binding.credentialGrantId, "credential binding grantId");
|
|
613
|
+
assertNonEmpty(binding.brokerGrantId, "credential broker grantId");
|
|
614
|
+
const expiry = parseTimestamp(binding.expiresAt, "credential binding expiry");
|
|
615
|
+
const requirement = plan.credentialRequirements.find((item) => item.credentialGrantId === binding.credentialGrantId);
|
|
616
|
+
if (!requirement || expiry < now.getTime() + requirement.ttlMs) {
|
|
617
|
+
throw unsupported("credential broker binding does not satisfy the requested TTL");
|
|
618
|
+
}
|
|
619
|
+
if (observedIds.has(binding.credentialGrantId))
|
|
620
|
+
throw unsupported("duplicate credential broker binding");
|
|
621
|
+
observedIds.add(binding.credentialGrantId);
|
|
622
|
+
}
|
|
623
|
+
if (requiredIds.size !== observedIds.size || [...requiredIds].some((id) => !observedIds.has(id))) {
|
|
624
|
+
throw unsupported("native preparation did not bind every credential requirement exactly once");
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
function validatePermitCoverage(prepared, permits, now) {
|
|
628
|
+
const writable = prepared.policy.grants.filter((grant) => grant.access === "read-write");
|
|
629
|
+
if (writable.length > 0 && permits.length === 0) {
|
|
630
|
+
throw new SandboxPlanError("TFWS_MISSING_MUTATION_PERMIT", "RW policy has no mutation permit");
|
|
631
|
+
}
|
|
632
|
+
const permitIds = new Set();
|
|
633
|
+
for (const permit of permits) {
|
|
634
|
+
validatePermit(permit, prepared.owner, now);
|
|
635
|
+
if (permitIds.has(permit.permitId))
|
|
636
|
+
throw invalidPermit("duplicate permit ID");
|
|
637
|
+
permitIds.add(permit.permitId);
|
|
638
|
+
if (!writable.some((grant) => permit.scopes.some((scope) => scopeCovers(scope, grant)))) {
|
|
639
|
+
throw invalidPermit(`permit ${permit.permitId} does not cover a writable grant`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
for (const grant of writable) {
|
|
643
|
+
if (!permits.some((permit) => permit.scopes.some((scope) => scopeCovers(scope, grant)))) {
|
|
644
|
+
throw new SandboxPlanError("TFWS_MISSING_MUTATION_PERMIT", `no permit covers ${grant.resourceDomainId}:${grant.logicalPrefix}`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
function validatePermit(permit, owner, now) {
|
|
649
|
+
if (!isIssuedMutationPermit(permit))
|
|
650
|
+
throw invalidPermit("permit was not issued by the mutation permit registry");
|
|
651
|
+
assertNonEmptyPermit(permit.permitId, "permitId");
|
|
652
|
+
assertNonEmptyPermit(permit.intentId, "intentId");
|
|
653
|
+
assertNonEmptyPermit(permit.nonce, "nonce");
|
|
654
|
+
if (!Number.isSafeInteger(permit.journalEpoch) || permit.journalEpoch < 0)
|
|
655
|
+
throw invalidPermit("invalid journalEpoch");
|
|
656
|
+
validateOwner(permit.owner);
|
|
657
|
+
if (!ownersEqual(permit.owner, owner))
|
|
658
|
+
throw invalidPermit("permit owner does not match execution owner");
|
|
659
|
+
const issuedAt = parsePermitTimestamp(permit.issuedAt, "permit issuedAt");
|
|
660
|
+
const expiresAt = parsePermitTimestamp(permit.expiresAt, "permit expiresAt");
|
|
661
|
+
if (issuedAt > now.getTime() || expiresAt <= now.getTime() || expiresAt <= issuedAt) {
|
|
662
|
+
throw invalidPermit("permit lifetime is invalid or expired");
|
|
663
|
+
}
|
|
664
|
+
if (!Array.isArray(permit.scopes) || permit.scopes.length === 0)
|
|
665
|
+
throw invalidPermit("permit has no scopes");
|
|
666
|
+
for (const scope of permit.scopes) {
|
|
667
|
+
assertNonEmptyPermit(scope.resourceDomainId, "permit scope resourceDomainId");
|
|
668
|
+
try {
|
|
669
|
+
assertPhysicalPath(scope.canonicalPrefix, "permit canonicalPrefix");
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
throw invalidPermit("permit canonicalPrefix is invalid");
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
function projectGrant(grant) {
|
|
677
|
+
return {
|
|
678
|
+
resourceDomainId: grant.resourceDomainId,
|
|
679
|
+
logicalWorkspaceId: grant.logicalWorkspaceId,
|
|
680
|
+
logicalPrefix: grant.logicalPrefix,
|
|
681
|
+
scopeKind: grant.scopeKind,
|
|
682
|
+
access: grant.access,
|
|
683
|
+
lifetime: grant.lifetime,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
function projectCredential(requirement) {
|
|
687
|
+
return {
|
|
688
|
+
credentialGrantId: requirement.credentialGrantId,
|
|
689
|
+
credentialId: requirement.credentialId,
|
|
690
|
+
audience: requirement.audience,
|
|
691
|
+
purpose: requirement.purpose,
|
|
692
|
+
ttlMs: requirement.ttlMs,
|
|
693
|
+
delivery: requirement.delivery,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
function projectBaselineMount(mount) {
|
|
697
|
+
return { mountId: mount.mountId, physicalPath: mount.physicalPath, access: mount.access };
|
|
698
|
+
}
|
|
699
|
+
function projectCredentialBinding(binding) {
|
|
700
|
+
return {
|
|
701
|
+
credentialGrantId: binding.credentialGrantId,
|
|
702
|
+
brokerGrantId: binding.brokerGrantId,
|
|
703
|
+
expiresAt: binding.expiresAt,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
function compareCanonical(left, right) {
|
|
707
|
+
const a = canonicalJson(left);
|
|
708
|
+
const b = canonicalJson(right);
|
|
709
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
710
|
+
}
|
|
711
|
+
function compareProjectedGrants(left, right) {
|
|
712
|
+
return compareStringTuple([left.resourceDomainId, left.logicalWorkspaceId, left.logicalPrefix, left.scopeKind, left.access], [right.resourceDomainId, right.logicalWorkspaceId, right.logicalPrefix, right.scopeKind, right.access]) || compareCanonical(left, right);
|
|
713
|
+
}
|
|
714
|
+
function compareProjectedCredentials(left, right) {
|
|
715
|
+
return compareStringTuple([left.credentialGrantId, left.audience, left.purpose], [right.credentialGrantId, right.audience, right.purpose]) || compareCanonical(left, right);
|
|
716
|
+
}
|
|
717
|
+
function compareStringTuple(left, right) {
|
|
718
|
+
for (let index = 0; index < Math.max(left.length, right.length); index++) {
|
|
719
|
+
const a = left[index] ?? "";
|
|
720
|
+
const b = right[index] ?? "";
|
|
721
|
+
if (a < b)
|
|
722
|
+
return -1;
|
|
723
|
+
if (a > b)
|
|
724
|
+
return 1;
|
|
725
|
+
}
|
|
726
|
+
return 0;
|
|
727
|
+
}
|
|
728
|
+
function validateLifetime(lifetime) {
|
|
729
|
+
if (lifetime.scope === "phase") {
|
|
730
|
+
assertExactKeys(lifetime, ["scope", "runId", "phaseId", "attemptId"], "phase lifetime");
|
|
731
|
+
assertNonEmpty(lifetime.runId, "lifetime runId");
|
|
732
|
+
assertNonEmpty(lifetime.phaseId, "lifetime phaseId");
|
|
733
|
+
assertNonEmpty(lifetime.attemptId, "lifetime attemptId");
|
|
734
|
+
}
|
|
735
|
+
else if (lifetime.scope === "run") {
|
|
736
|
+
assertExactKeys(lifetime, ["scope", "runId"], "run lifetime");
|
|
737
|
+
assertNonEmpty(lifetime.runId, "lifetime runId");
|
|
738
|
+
}
|
|
739
|
+
else if (lifetime.scope === "external") {
|
|
740
|
+
assertExactKeys(lifetime, ["scope", "bindingId", "providerInstanceId"], "external lifetime");
|
|
741
|
+
assertNonEmpty(lifetime.bindingId, "lifetime bindingId");
|
|
742
|
+
}
|
|
743
|
+
else {
|
|
744
|
+
throw invalid("invalid capability lifetime");
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
function validateAccess(access) {
|
|
748
|
+
if (access !== "read-only" && access !== "read-write")
|
|
749
|
+
throw invalid("invalid workspace access");
|
|
750
|
+
}
|
|
751
|
+
function validateOwner(owner) {
|
|
752
|
+
for (const [name, value] of Object.entries({
|
|
753
|
+
runId: owner.runId,
|
|
754
|
+
phaseId: owner.phaseId,
|
|
755
|
+
attemptId: owner.attemptId,
|
|
756
|
+
unitId: owner.unitId,
|
|
757
|
+
}))
|
|
758
|
+
assertNonEmpty(value, `owner ${name}`);
|
|
759
|
+
if (!Array.isArray(owner.ancestry) || owner.ancestry.some((item) => typeof item !== "string" || item.length === 0)) {
|
|
760
|
+
throw invalid("owner ancestry must be non-empty strings");
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
function validateLogicalPrefix(prefix) {
|
|
764
|
+
if (typeof prefix !== "string" || prefix.includes("\\") || prefix.includes("\0") || prefix.startsWith("/")) {
|
|
765
|
+
throw invalid("logical prefix must be a portable relative path");
|
|
766
|
+
}
|
|
767
|
+
if (prefix === "")
|
|
768
|
+
return;
|
|
769
|
+
const segments = prefix.split("/");
|
|
770
|
+
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
|
771
|
+
throw invalid("logical prefix contains an invalid segment");
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
function validateCapabilities(capabilities) {
|
|
775
|
+
assertCapabilities(capabilities);
|
|
776
|
+
}
|
|
777
|
+
function assertCapabilities(capabilities) {
|
|
778
|
+
if (!capabilities || typeof capabilities !== "object" || Array.isArray(capabilities)) {
|
|
779
|
+
throw invalid("backend capabilities must be an object");
|
|
780
|
+
}
|
|
781
|
+
assertExactKeys(capabilities, [
|
|
782
|
+
"schemaVersion",
|
|
783
|
+
"backendId",
|
|
784
|
+
"backendCapabilityVersion",
|
|
785
|
+
"agent",
|
|
786
|
+
"script",
|
|
787
|
+
"sandboxFeatures",
|
|
788
|
+
"brokeredRead",
|
|
789
|
+
"brokeredWrite",
|
|
790
|
+
"versionCommitModes",
|
|
791
|
+
"restoreStrategies",
|
|
792
|
+
"baselinePolicyId",
|
|
793
|
+
], "backend capabilities");
|
|
794
|
+
if (capabilities.schemaVersion !== 1)
|
|
795
|
+
throw invalid("backend capability schemaVersion must be 1");
|
|
796
|
+
assertNonEmpty(capabilities.backendId, "backendId");
|
|
797
|
+
assertNonEmpty(capabilities.backendCapabilityVersion, "backendCapabilityVersion");
|
|
798
|
+
assertNonEmpty(capabilities.baselinePolicyId, "baselinePolicyId");
|
|
799
|
+
const executionModes = ["native-single-root", "native-multi-root", "resolve-only"];
|
|
800
|
+
if (!executionModes.includes(capabilities.agent))
|
|
801
|
+
throw invalid("backend agent mode is invalid");
|
|
802
|
+
if (!executionModes.includes(capabilities.script))
|
|
803
|
+
throw invalid("backend script mode is invalid");
|
|
804
|
+
if (!capabilities.sandboxFeatures || typeof capabilities.sandboxFeatures !== "object" || Array.isArray(capabilities.sandboxFeatures)) {
|
|
805
|
+
throw invalid("sandboxFeatures must be an object");
|
|
806
|
+
}
|
|
807
|
+
const features = capabilities.sandboxFeatures;
|
|
808
|
+
assertExactKeys(features, [
|
|
809
|
+
"maxGrants",
|
|
810
|
+
"scopeKinds",
|
|
811
|
+
"perGrantAccess",
|
|
812
|
+
"denyAmbientUserData",
|
|
813
|
+
"exactBaselineMounts",
|
|
814
|
+
"privateTempPerExecution",
|
|
815
|
+
"descendantEnforcement",
|
|
816
|
+
"raceFreeFileBroker",
|
|
817
|
+
"networkModes",
|
|
818
|
+
"credentialModes",
|
|
819
|
+
], "sandboxFeatures");
|
|
820
|
+
if (!Number.isSafeInteger(features.maxGrants) || features.maxGrants < 1) {
|
|
821
|
+
throw invalid("sandbox maxGrants must be a positive safe integer");
|
|
822
|
+
}
|
|
823
|
+
for (const field of [
|
|
824
|
+
"perGrantAccess",
|
|
825
|
+
"denyAmbientUserData",
|
|
826
|
+
"exactBaselineMounts",
|
|
827
|
+
"privateTempPerExecution",
|
|
828
|
+
"descendantEnforcement",
|
|
829
|
+
"raceFreeFileBroker",
|
|
830
|
+
]) {
|
|
831
|
+
if (typeof features[field] !== "boolean")
|
|
832
|
+
throw invalid(`sandboxFeatures.${field} must be boolean`);
|
|
833
|
+
}
|
|
834
|
+
if (typeof capabilities.brokeredRead !== "boolean" || typeof capabilities.brokeredWrite !== "boolean") {
|
|
835
|
+
throw invalid("brokeredRead and brokeredWrite must be boolean");
|
|
836
|
+
}
|
|
837
|
+
assertEnumArray(features.scopeKinds, ["file", "directory"], "sandboxFeatures.scopeKinds", true);
|
|
838
|
+
assertEnumArray(features.networkModes, ["host-policy", "none"], "sandboxFeatures.networkModes", true);
|
|
839
|
+
assertEnumArray(features.credentialModes, ["opaque-broker", "isolated-host-process"], "sandboxFeatures.credentialModes", false);
|
|
840
|
+
assertEnumArray(capabilities.versionCommitModes, ["content-snapshot", "generation-only", "unavailable"], "versionCommitModes", true);
|
|
841
|
+
assertEnumArray(capabilities.restoreStrategies, ["replace-scope", "provider-native"], "restoreStrategies", false);
|
|
842
|
+
const advertisesNative = capabilities.agent !== "resolve-only" || capabilities.script !== "resolve-only";
|
|
843
|
+
if (advertisesNative && (!features.raceFreeFileBroker || !capabilities.brokeredRead || !capabilities.brokeredWrite)) {
|
|
844
|
+
throw invalid("native execution requires a race-free broker with brokered read and write");
|
|
845
|
+
}
|
|
846
|
+
if (advertisesNative && capabilities.versionCommitModes.every((mode) => mode === "unavailable")) {
|
|
847
|
+
throw invalid("native execution requires an enforceable version commit mode");
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
function assertEnumArray(value, allowed, name, requireNonEmpty) {
|
|
851
|
+
if (!Array.isArray(value) || (requireNonEmpty && value.length === 0)) {
|
|
852
|
+
throw invalid(`${name} must be ${requireNonEmpty ? "a non-empty" : "an"} array`);
|
|
853
|
+
}
|
|
854
|
+
const seen = new Set();
|
|
855
|
+
for (const item of value) {
|
|
856
|
+
if (typeof item !== "string" || !allowed.includes(item))
|
|
857
|
+
throw invalid(`${name} contains an invalid value`);
|
|
858
|
+
if (seen.has(item))
|
|
859
|
+
throw invalid(`${name} contains a duplicate value`);
|
|
860
|
+
seen.add(item);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
function freezePolicy(plan) {
|
|
864
|
+
return deepFreeze(structuredClone(plan));
|
|
865
|
+
}
|
|
866
|
+
function freezeOwner(owner) {
|
|
867
|
+
return deepFreeze(structuredClone(owner));
|
|
868
|
+
}
|
|
869
|
+
function freezePermit(permit) {
|
|
870
|
+
// MutationPermitRegistry already returns a deeply frozen, WeakSet-branded
|
|
871
|
+
// capability. Cloning it would erase that live brand and make the sealed
|
|
872
|
+
// plan impossible to activate against the durable registry. Retain the exact
|
|
873
|
+
// issued object after seal-time validation; reconstructed objects never reach
|
|
874
|
+
// this point because validatePermit() rejects them first.
|
|
875
|
+
return permit;
|
|
876
|
+
}
|
|
877
|
+
function deepFreeze(value) {
|
|
878
|
+
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
|
|
879
|
+
for (const item of Object.values(value))
|
|
880
|
+
deepFreeze(item);
|
|
881
|
+
Object.freeze(value);
|
|
882
|
+
}
|
|
883
|
+
return value;
|
|
884
|
+
}
|
|
885
|
+
function accessCovers(outer, inner) {
|
|
886
|
+
return outer === "read-write" || inner === "read-only";
|
|
887
|
+
}
|
|
888
|
+
function physicalContains(root, candidate) {
|
|
889
|
+
const relative = path.relative(root, candidate);
|
|
890
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
891
|
+
}
|
|
892
|
+
function scopeCovers(scope, grant) {
|
|
893
|
+
if (scope.resourceDomainId !== grant.resourceDomainId)
|
|
894
|
+
return false;
|
|
895
|
+
return physicalContains(scope.canonicalPrefix, grant.physicalScopeRoot);
|
|
896
|
+
}
|
|
897
|
+
function ownersEqual(left, right) {
|
|
898
|
+
return canonicalJson(left) === canonicalJson(right);
|
|
899
|
+
}
|
|
900
|
+
function assertPhysicalPath(value, name) {
|
|
901
|
+
if (typeof value !== "string" || value.length === 0 || value.includes("\0") || !path.isAbsolute(value)) {
|
|
902
|
+
throw invalid(`${name} must be a non-empty absolute path`);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
function assertDigest(value, name, prefix = "") {
|
|
906
|
+
const suffix = prefix === "" ? value : value.slice(prefix.length);
|
|
907
|
+
if ((prefix && !value.startsWith(prefix)) || !/^[a-f0-9]{64}$/.test(suffix)) {
|
|
908
|
+
throw invalid(`${name} must be ${prefix || ""}64 lowercase SHA-256 hex characters`);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
function assertNonEmpty(value, name) {
|
|
912
|
+
if (typeof value !== "string" || value.length === 0 || value.includes("\0"))
|
|
913
|
+
throw invalid(`${name} is invalid`);
|
|
914
|
+
}
|
|
915
|
+
function assertExactKeys(value, allowed, name) {
|
|
916
|
+
const allowedKeys = new Set(allowed);
|
|
917
|
+
const unexpected = Object.keys(value).filter((key) => !allowedKeys.has(key));
|
|
918
|
+
if (unexpected.length > 0)
|
|
919
|
+
throw invalid(`${name} has unsigned or unsupported fields: ${unexpected.join(", ")}`);
|
|
920
|
+
}
|
|
921
|
+
function assertNonEmptyPermit(value, name) {
|
|
922
|
+
if (typeof value !== "string" || value.length === 0 || value.includes("\0"))
|
|
923
|
+
throw invalidPermit(`${name} is invalid`);
|
|
924
|
+
}
|
|
925
|
+
function parseTimestamp(value, name) {
|
|
926
|
+
const parsed = Date.parse(value);
|
|
927
|
+
if (!Number.isFinite(parsed))
|
|
928
|
+
throw invalid(`${name} must be an ISO timestamp`);
|
|
929
|
+
return parsed;
|
|
930
|
+
}
|
|
931
|
+
function parsePermitTimestamp(value, name) {
|
|
932
|
+
const parsed = Date.parse(value);
|
|
933
|
+
if (!Number.isFinite(parsed))
|
|
934
|
+
throw invalidPermit(`${name} must be an ISO timestamp`);
|
|
935
|
+
return parsed;
|
|
936
|
+
}
|
|
937
|
+
function baselineKey(policyId, version) {
|
|
938
|
+
return `${policyId}\0${version}`;
|
|
939
|
+
}
|
|
940
|
+
function invalid(message) {
|
|
941
|
+
return new SandboxPlanError("TFWS_INVALID_POLICY", message);
|
|
942
|
+
}
|
|
943
|
+
function invalidPermit(message) {
|
|
944
|
+
return new SandboxPlanError("TFWS_INVALID_MUTATION_PERMIT", message);
|
|
945
|
+
}
|
|
946
|
+
function unsupported(message) {
|
|
947
|
+
return new SandboxPlanError("TFWS_UNSUPPORTED_SANDBOX_POLICY", message);
|
|
948
|
+
}
|
|
949
|
+
// Kept as a named export seam for conformance tools without implying that a
|
|
950
|
+
// capability advertisement proves enforcement.
|
|
951
|
+
export const validateWorkspaceBackendCapabilities = validateCapabilities;
|
|
952
|
+
//# sourceMappingURL=sandbox.js.map
|