diff --git a/src/carrier-binding.test.ts b/src/carrier-binding.test.ts index f386cf7a6..818ff203e 100644 --- a/src/carrier-binding.test.ts +++ b/src/carrier-binding.test.ts @@ -1617,3 +1617,428 @@ test("terminal hygiene releases only the exact normally closed cutover lease aft f.close(); } }); + +test("NAB-E2 Issue #287: 12 Mandatory Hostile Controls for recursive multi-hop delegation", async () => { + const f = fixture(); + try { + const subDir = join(f.workspace, "sub"); + mkdirSync(subDir, { recursive: true }); + + // ------------------------------------------------------------------------- + // Control 1: valid Main -> Worker -> Subagent narrowed chain succeeds + // ------------------------------------------------------------------------- + const rootContract: CarrierContract = { + repository: "James3014/devspace", + goal: "issue287", + role: "controller", + scope: [f.workspace], + baseRevision: "a".repeat(40), + operations: ["dependency_sync"], + expiresAt: new Date(f.clock() + 180000).toISOString(), + maxDepth: 2, + }; + const rootPairing = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c1-root" }); + const rootApproved = f.store.approveLocal(rootPairing.pendingId, rootContract); + f.store.redeem({ clientId: "shared-oauth", sessionId: "c1-root" }, rootPairing.credential); + + // Hop 1: Controller -> Worker + const workerPairing = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c1-worker" }); + const workerContract: CarrierContract = { + ...rootContract, + role: "worker", + maxDepth: 1, + expiresAt: new Date(f.clock() + 120000).toISOString(), + }; + const workerApproved = f.store.delegate({ clientId: "shared-oauth", sessionId: "c1-root" }, workerPairing.pendingId, workerContract); + f.store.redeem({ clientId: "shared-oauth", sessionId: "c1-worker" }, workerPairing.credential); + + // Hop 2: Worker -> Subagent (with narrowed scope to subDir) + const subagentPairing = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c1-subagent" }); + const subagentContract: CarrierContract = { + ...workerContract, + role: "worker", + scope: [subDir], + maxDepth: 0, + expiresAt: new Date(f.clock() + 60000).toISOString(), + }; + const subagentApproved = f.store.delegate({ clientId: "shared-oauth", sessionId: "c1-worker" }, subagentPairing.pendingId, subagentContract); + const subagentActive = f.store.redeem({ clientId: "shared-oauth", sessionId: "c1-subagent" }, subagentPairing.credential); + + // Verify deterministic ancestry chain identity and root authority reference + const expectedGeneration = `${rootApproved.id}:1/${workerApproved.id}:1/${subagentApproved.id}:1`; + assert.equal(subagentActive.authorityVersion, expectedGeneration); + assert.equal(subagentActive.grant.coordinatorThread, rootApproved.id); + + // Subagent executes physical effect + const subject1 = { + operationId: "c1-op-success", + requestHash: "1".repeat(64), + workspaceRoot: subDir, + baseRevision: rootContract.baseRevision, + operation: "dependency_sync" as const, + }; + const lease1 = f.store.prepareEffect({ clientId: "shared-oauth", sessionId: "c1-subagent" }, subject1); + assert.equal(lease1.ownerThread, subagentApproved.id); + assert.equal(lease1.grant.coordinatorThread, rootApproved.id); + + const pinned1 = f.store.ownership.beginOperation({ clientId: "shared-oauth", sessionId: "c1-subagent" }, lease1.leaseId, lease1.version, subject1.operationId); + f.db.sqlite.prepare("insert into dependency_terminal_witnesses values(?,?,?,?,?)") + .run(subject1.operationId, subject1.requestHash, lease1.leaseId, 0, 1); + + const evidence1 = f.store.readers.readDependencyReconciliation?.({ clientId: "shared-oauth", sessionId: "c1-subagent" }, subject1); + assert.ok(evidence1); + assert.equal(evidence1.state, "finished"); + assert.ok(evidence1.detail); + const parsedDetail1 = JSON.parse(evidence1.detail); + assert.equal(parsedDetail1.authorityVersion, expectedGeneration); + assert.equal(parsedDetail1.rootAuthority, rootApproved.id); + + const { requestHash, exitCode, frozenInputsUnchanged, ...ownershipEvidence1 } = evidence1; + const receipt1 = f.store.ownership.reconcile( + { clientId: "shared-oauth", sessionId: "c1-subagent" }, + lease1.leaseId, + pinned1.version, + { + ...ownershipEvidence1, + detail: JSON.stringify({ requestHash, exitCode, frozenInputsUnchanged, detail: evidence1.detail }), + }, + ); + assert.ok(receipt1); + assert.equal(receipt1.evidence.state, "finished"); + + const replay1 = f.store.readers.readDependencyReconciliation?.({ clientId: "shared-oauth", sessionId: "c1-subagent" }, subject1); + assert.ok(replay1); + assert.equal(replay1.exitCode, 0); + assert.equal(replay1.frozenInputsUnchanged, true); + + // ------------------------------------------------------------------------- + // Control 2: child action widening rejects pre-effect + // ------------------------------------------------------------------------- + const c2Pairing = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c2-subagent" }); + const c2ContractWidenedOps: CarrierContract = { + ...subagentContract, + operations: ["dependency_sync", "cutover_start"], + }; + assert.throws( + () => f.store.delegate({ clientId: "shared-oauth", sessionId: "c1-worker" }, c2Pairing.pendingId, c2ContractWidenedOps), + /Delegation exceeds parent scope|Cutover authority cannot be delegated/, + ); + + // ------------------------------------------------------------------------- + // Control 3: filesystem/scope widening rejects + // ------------------------------------------------------------------------- + const c3Pairing = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c3-subagent" }); + const c3ContractWidenedScope: CarrierContract = { + ...subagentContract, + scope: [f.root], + }; + assert.throws( + () => f.store.delegate({ clientId: "shared-oauth", sessionId: "c1-worker" }, c3Pairing.pendingId, c3ContractWidenedScope), + /Delegation exceeds parent scope/, + ); + + // ------------------------------------------------------------------------- + // Control 4: descendant merge/Owner-only authority request rejects + // ------------------------------------------------------------------------- + const c4Pairing1 = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c4-subagent-1" }); + assert.throws( + () => f.store.delegate({ clientId: "shared-oauth", sessionId: "c1-worker" }, c4Pairing1.pendingId, { + ...subagentContract, + role: "controller", + }), + /Delegation cannot create a controller/, + ); + + const c4Pairing2 = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c4-subagent-2" }); + assert.throws( + () => f.store.delegate({ clientId: "shared-oauth", sessionId: "c1-worker" }, c4Pairing2.pendingId, { + ...subagentContract, + cutover: { + stateRoot: f.root, + attemptKey: "c4-cutover", + currentIdentity: { serverInstanceId: "old", sourceCommit: rootContract.baseRevision, buildId: "b1", capabilityManifestSha256: "0".repeat(64) }, + expectedIdentity: { sourceCommit: "2".repeat(40), buildId: "b2", capabilityManifestSha256: "3".repeat(64) }, + expiresAt: new Date(f.clock() + 30000).toISOString(), + restart: { buildReady: { verifiedBy: "t", verifiedAt: new Date(f.clock()).toISOString(), evidence: "e" }, actuator: "launchd-self", serviceLabel: "s", launchdTarget: "t" }, + finish: { workspaceId: "w", agentId: "a" }, + }, + }), + /Cutover authority cannot be delegated/, + ); + + // ------------------------------------------------------------------------- + // Control 5: ancestor revocation fences already-issued descendant + // ------------------------------------------------------------------------- + const c5RootPair = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c5-root" }); + const c5Root = f.store.approveLocal(c5RootPair.pendingId, rootContract); + f.store.redeem({ clientId: "shared-oauth", sessionId: "c5-root" }, c5RootPair.credential); + + const c5WorkerPair = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c5-worker" }); + const c5Worker = f.store.delegate({ clientId: "shared-oauth", sessionId: "c5-root" }, c5WorkerPair.pendingId, workerContract); + f.store.redeem({ clientId: "shared-oauth", sessionId: "c5-worker" }, c5WorkerPair.credential); + + const c5SubPair = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c5-sub" }); + const c5Sub = f.store.delegate({ clientId: "shared-oauth", sessionId: "c5-worker" }, c5SubPair.pendingId, subagentContract); + f.store.redeem({ clientId: "shared-oauth", sessionId: "c5-sub" }, c5SubPair.credential); + + assert.equal(f.store.status({ clientId: "shared-oauth", sessionId: "c5-sub" }).id, c5Sub.id); + + // Revoke intermediate worker + f.store.revokeLocal(c5Worker.id, 1); + assert.throws( + () => f.store.status({ clientId: "shared-oauth", sessionId: "c5-sub" }), + /Carrier expired or revoked/, + ); + assert.throws( + () => f.store.prepareEffect({ clientId: "shared-oauth", sessionId: "c5-sub" }, { + operationId: "c5-op", + requestHash: "5".repeat(64), + workspaceRoot: subDir, + baseRevision: rootContract.baseRevision, + operation: "dependency_sync", + }), + /Carrier expired or revoked/, + ); + + // Revoke root in a separate chain + const c5bRootPair = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c5b-root" }); + const c5bRoot = f.store.approveLocal(c5bRootPair.pendingId, rootContract); + f.store.redeem({ clientId: "shared-oauth", sessionId: "c5b-root" }, c5bRootPair.credential); + + const c5bWorkerPair = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c5b-worker" }); + const c5bWorker = f.store.delegate({ clientId: "shared-oauth", sessionId: "c5b-root" }, c5bWorkerPair.pendingId, workerContract); + f.store.redeem({ clientId: "shared-oauth", sessionId: "c5b-worker" }, c5bWorkerPair.credential); + + const c5bSubPair = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c5b-sub" }); + const c5bSub = f.store.delegate({ clientId: "shared-oauth", sessionId: "c5b-worker" }, c5bSubPair.pendingId, subagentContract); + f.store.redeem({ clientId: "shared-oauth", sessionId: "c5b-sub" }, c5bSubPair.credential); + + f.store.revokeLocal(c5bRoot.id, 1); + assert.throws( + () => f.store.status({ clientId: "shared-oauth", sessionId: "c5b-sub" }), + /Carrier expired or revoked/, + ); + + // ------------------------------------------------------------------------- + // Control 6: expired parent with apparently-valid child rejects + // ------------------------------------------------------------------------- + const c6RootPair = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c6-root" }); + const c6RootContract: CarrierContract = { + ...rootContract, + expiresAt: new Date(f.clock() + 300000).toISOString(), + }; + f.store.approveLocal(c6RootPair.pendingId, c6RootContract); + f.store.redeem({ clientId: "shared-oauth", sessionId: "c6-root" }, c6RootPair.credential); + + const c6WorkerPair = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c6-worker" }); + const c6WorkerContract: CarrierContract = { + ...workerContract, + expiresAt: new Date(f.clock() + 40000).toISOString(), + }; + const c6Worker = f.store.delegate({ clientId: "shared-oauth", sessionId: "c6-root" }, c6WorkerPair.pendingId, c6WorkerContract); + f.store.redeem({ clientId: "shared-oauth", sessionId: "c6-worker" }, c6WorkerPair.credential); + + const c6SubPair = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c6-sub" }); + const c6SubContract: CarrierContract = { + ...subagentContract, + expiresAt: new Date(f.clock() + 30000).toISOString(), + }; + const c6Sub = f.store.delegate({ clientId: "shared-oauth", sessionId: "c6-worker" }, c6SubPair.pendingId, c6SubContract); + f.store.redeem({ clientId: "shared-oauth", sessionId: "c6-sub" }, c6SubPair.credential); + + // Alter child validity in database to extend into future + f.db.sqlite.prepare("update carrier_validity set expires_at=? where carrier_id=?") + .run(new Date(f.clock() + 200000).toISOString(), c6Sub.id); + + // Advance clock past worker expiration + f.advance(50000); + assert.throws( + () => f.store.status({ clientId: "shared-oauth", sessionId: "c6-sub" }), + /Carrier validity expired/, + ); + + // ------------------------------------------------------------------------- + // Control 7: root-principal substitution rejects + // ------------------------------------------------------------------------- + const rootBPairing = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c7-root-b" }); + const rootBContract: CarrierContract = { + ...rootContract, + goal: "issue287-other-root", + }; + const rootB = f.store.approveLocal(rootBPairing.pendingId, rootBContract); + f.store.redeem({ clientId: "shared-oauth", sessionId: "c7-root-b" }, rootBPairing.credential); + + // Tamper child's parent_id in DB to point directly to Root B + f.db.sqlite.prepare("update carrier_bindings set parent_id=? where id=?") + .run(rootB.id, subagentApproved.id); + + assert.throws( + () => f.store.status({ clientId: "shared-oauth", sessionId: "c1-subagent" }), + /Delegation cross-stitching detected|Delegation exceeds parent scope/, + ); + + // Restore parent_id + f.db.sqlite.prepare("update carrier_bindings set parent_id=? where id=?") + .run(workerApproved.id, subagentApproved.id); + + // ------------------------------------------------------------------------- + // Control 8: cross-repository / cross-goal chain stitching rejects + // ------------------------------------------------------------------------- + const c8Pairing = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c8-subagent" }); + const c8StitchedRepo: CarrierContract = { + ...subagentContract, + repository: "James3014/other-repo", + }; + assert.throws( + () => f.store.delegate({ clientId: "shared-oauth", sessionId: "c1-worker" }, c8Pairing.pendingId, c8StitchedRepo), + /Delegation exceeds parent scope/, + ); + const c8StitchedGoal: CarrierContract = { + ...subagentContract, + goal: "completely-different-goal", + }; + assert.throws( + () => f.store.delegate({ clientId: "shared-oauth", sessionId: "c1-worker" }, c8Pairing.pendingId, c8StitchedGoal), + /Delegation exceeds parent scope/, + ); + + // ------------------------------------------------------------------------- + // Control 9: same operation with different delegation chain rejects or requires reconciliation + // ------------------------------------------------------------------------- + const worker2Pairing = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c9-worker2" }); + f.store.delegate({ clientId: "shared-oauth", sessionId: "c1-root" }, worker2Pairing.pendingId, workerContract); + f.store.redeem({ clientId: "shared-oauth", sessionId: "c9-worker2" }, worker2Pairing.credential); + + const subagent2Pairing = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c9-subagent2" }); + f.store.delegate({ clientId: "shared-oauth", sessionId: "c9-worker2" }, subagent2Pairing.pendingId, subagentContract); + f.store.redeem({ clientId: "shared-oauth", sessionId: "c9-subagent2" }, subagent2Pairing.credential); + + const sharedSubject = { + operationId: "c9-shared-op", + requestHash: "9".repeat(64), + workspaceRoot: subDir, + baseRevision: rootContract.baseRevision, + operation: "dependency_sync" as const, + }; + + const leaseChain1 = f.store.prepareEffect({ clientId: "shared-oauth", sessionId: "c1-subagent" }, sharedSubject); + assert.equal(leaseChain1.ownerThread, subagentApproved.id); + + // Chain 2 attempts to prepare the SAME operation before reconciliation + assert.throws( + () => f.store.prepareEffect({ clientId: "shared-oauth", sessionId: "c9-subagent2" }, sharedSubject), + /Operation is bound to a different delegation chain|Operation was previously bound to a different delegation chain/, + ); + + // ------------------------------------------------------------------------- + // Control 10: leaf-only / truncated chain cannot satisfy a consequential-effect claim + // ------------------------------------------------------------------------- + const orphanLeafPairing = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c10-orphan" }); + assert.throws( + () => f.store.approveLocal(orphanLeafPairing.pendingId, subagentContract), + /Root carrier must be a controller/, + ); + + const c10Subject = { + operationId: "c10-op", + requestHash: "a".repeat(64), + workspaceRoot: subDir, + baseRevision: rootContract.baseRevision, + operation: "dependency_sync" as const, + }; + const c10Lease = f.store.prepareEffect({ clientId: "shared-oauth", sessionId: "c1-subagent" }, c10Subject); + const c10Pinned = f.store.ownership.beginOperation({ clientId: "shared-oauth", sessionId: "c1-subagent" }, c10Lease.leaseId, c10Lease.version, c10Subject.operationId); + f.db.sqlite.prepare("insert into dependency_terminal_witnesses values(?,?,?,?,?)") + .run(c10Subject.operationId, c10Subject.requestHash, c10Lease.leaseId, 0, 1); + + // Attacker submits truncated authorityVersion (only leaf) + const truncatedEvidence = { + leaseId: c10Lease.leaseId, + ownerThread: subagentApproved.id, + operationHandle: c10Subject.operationId, + operation: c10Subject.operation, + baseRevision: c10Subject.baseRevision, + leaseVersion: c10Pinned.version, + state: "finished" as const, + detail: JSON.stringify({ + requestHash: c10Subject.requestHash, + exitCode: 0, + frozenInputsUnchanged: true, + authorityVersion: `${subagentApproved.id}:1`, + rootAuthority: rootApproved.id, + }), + }; + assert.throws( + () => f.store.ownership.reconcile({ clientId: "shared-oauth", sessionId: "c1-subagent" }, c10Lease.leaseId, c10Pinned.version, truncatedEvidence), + /trusted terminal effect proof required/, + ); + + // ------------------------------------------------------------------------- + // Control 11: existing Controller -> Worker behavior does not regress + // ------------------------------------------------------------------------- + const c11Dir = join(f.workspace, "c11"); + const c12Dir = join(f.workspace, "c12"); + mkdirSync(c11Dir, { recursive: true }); + mkdirSync(c12Dir, { recursive: true }); + + const standardRootPairing = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c11-root" }); + const standardRootContract: CarrierContract = { + repository: "James3014/devspace", + goal: "c11-regression-check", + role: "controller", + scope: [c11Dir, c12Dir], + baseRevision: "b".repeat(40), + operations: ["dependency_sync"], + expiresAt: new Date(f.clock() + 120000).toISOString(), + }; + const standardRoot = f.store.approveLocal(standardRootPairing.pendingId, standardRootContract); + f.store.redeem({ clientId: "shared-oauth", sessionId: "c11-root" }, standardRootPairing.credential); + + const standardWorkerPairing = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c11-worker" }); + const standardWorkerContract: CarrierContract = { + ...standardRootContract, + role: "worker", + scope: [c11Dir], + expiresAt: new Date(f.clock() + 60000).toISOString(), + }; + const standardWorker = f.store.delegate({ clientId: "shared-oauth", sessionId: "c11-root" }, standardWorkerPairing.pendingId, standardWorkerContract); + const standardWorkerActive = f.store.redeem({ clientId: "shared-oauth", sessionId: "c11-worker" }, standardWorkerPairing.credential); + assert.equal(standardWorkerActive.id, standardWorker.id); + assert.equal(standardWorkerActive.grant.coordinatorThread, standardRoot.id); + + const c11Subject = { + operationId: "c11-op", + requestHash: "c".repeat(64), + workspaceRoot: c11Dir, + baseRevision: standardRootContract.baseRevision, + operation: "dependency_sync" as const, + }; + const c11Lease = f.store.prepareEffect({ clientId: "shared-oauth", sessionId: "c11-worker" }, c11Subject); + assert.equal(c11Lease.ownerThread, standardWorker.id); + + // ------------------------------------------------------------------------- + // Control 12: no-subdelegation work does not require synthetic meaningless hops + // ------------------------------------------------------------------------- + const directSubject = { + operationId: "c12-direct-op", + requestHash: "d".repeat(64), + workspaceRoot: c12Dir, + baseRevision: standardRootContract.baseRevision, + operation: "dependency_sync" as const, + }; + const directLease = f.store.prepareEffect({ clientId: "shared-oauth", sessionId: "c11-root" }, directSubject); + assert.equal(directLease.ownerThread, standardRoot.id); + assert.equal(directLease.grant.coordinatorThread, standardRoot.id); + + const c12SubPairing = f.store.requestPairing({ clientId: "shared-oauth", sessionId: "c12-sub" }); + assert.throws( + () => f.store.delegate({ clientId: "shared-oauth", sessionId: "c11-worker" }, c12SubPairing.pendingId, { + ...standardWorkerContract, + role: "worker", + }), + /Workers cannot delegate without remaining delegation depth/, + ); + } finally { + f.close(); + } +}); diff --git a/src/carrier-binding.ts b/src/carrier-binding.ts index bf079a6cb..5c4baa279 100644 --- a/src/carrier-binding.ts +++ b/src/carrier-binding.ts @@ -52,7 +52,11 @@ export interface CarrierContract { operations: Array<"dependency_sync" | "cutover_start">; expiresAt: string; cutover?: CarrierCutoverContract; + maxDepth?: number; + remainingDepth?: number; } +const MAX_DELEGATION_LINEAGE = 32; +const MAX_DELEGATION_DEPTH = MAX_DELEGATION_LINEAGE - 1; const nonempty=z.string().min(1).max(4096); const revision=z.string().regex(/^[a-f0-9]{40,64}$/); const timestamp=z.string().datetime(); @@ -74,7 +78,12 @@ interface PairingRow { expires_at: number; binding_id: string | null; } interface Validity { version: number; expires_at: string; } -interface Binding { row: BindingRow; contract: CarrierContract; root: BindingRow; validity: Validity; generation: string; } +interface Binding { row: BindingRow; contract: CarrierContract; root: BindingRow; validity: Validity; generation: string; chain: string[]; } +function contractRemainingDepth(contract: CarrierContract): number | undefined { + if (contract.remainingDepth !== undefined) return contract.remainingDepth; + if (contract.maxDepth !== undefined) return contract.maxDepth; + return undefined; +} const subjectJson = (s: EffectSubject) => JSON.stringify({operationId:s.operationId,requestHash:s.requestHash,workspaceRoot:s.workspaceRoot,baseRevision:s.baseRevision,operation:s.operation}); const digest = (value: string) => createHash("sha256").update(value).digest("hex"); function deny(message = "A current paired carrier is required"): never { @@ -169,20 +178,57 @@ export class CarrierBindingStore { const proof=JSON.parse(previous.evidence_json); const detail=JSON.parse(proof.detail??"{}"); if(proof.ownerThread!==binding.row.id || proof.operationHandle!==subject.operationId || detail.requestHash!==subject.requestHash) return undefined; + let receiptAuthority = detail.authorityVersion; + let receiptRoot = detail.rootAuthority; + if (detail.detail) { + try { + const inner = typeof detail.detail === "string" ? JSON.parse(detail.detail) : detail.detail; + if (inner && typeof inner === "object") { + receiptAuthority = receiptAuthority ?? (inner as Record).authorityVersion; + receiptRoot = receiptRoot ?? (inner as Record).rootAuthority; + } + } catch {} + } + if (receiptAuthority !== undefined && receiptAuthority !== binding.generation) return undefined; + if (receiptRoot !== undefined && receiptRoot !== binding.root.id) return undefined; + if (binding.row.parent_id !== null && (!receiptAuthority || !receiptRoot)) return undefined; const {detail:_,...identity}=proof; - return {...identity,requestHash:detail.requestHash,exitCode:detail.exitCode,frozenInputsUnchanged:detail.frozenInputsUnchanged}; + return { + ...identity, + requestHash:detail.requestHash, + exitCode:detail.exitCode, + frozenInputsUnchanged:detail.frozenInputsUnchanged, + ...(detail.detail !== undefined ? { detail: typeof detail.detail === "string" ? detail.detail : JSON.stringify(detail.detail) } : {}), + }; } const witness=this.terminal(subject.operationId); if(!witness || witness.request_hash!==subject.requestHash || witness.lease_id!==lease.leaseId) return undefined; return {leaseId:lease.leaseId,ownerThread:lease.ownerThread,operationHandle:subject.operationId,operation:subject.operation,baseRevision:subject.baseRevision,leaseVersion:lease.version, - state:witness.exit_code===0 && witness.frozen_inputs_unchanged===1 ? "finished" : "failed",requestHash:subject.requestHash,exitCode:witness.exit_code,frozenInputsUnchanged:witness.frozen_inputs_unchanged===1}; + state:witness.exit_code===0 && witness.frozen_inputs_unchanged===1 ? "finished" : "failed",requestHash:subject.requestHash,exitCode:witness.exit_code,frozenInputsUnchanged:witness.frozen_inputs_unchanged===1, + detail:JSON.stringify({authorityVersion:binding.generation,rootAuthority:binding.root.id})}; }, verifyDependencyReconciliation: (evidence,subject) => this.verifyTerminal(evidence,subject), verifyReconciliationEvidence: (evidence,lease,owner) => { - this.active(owner.ownerThread); + const binding=this.active(owner.ownerThread); if(evidence.operation==="cutover_start") return this.verifyCutoverTerminal(evidence,lease,owner.ownerThread); - let detail: {requestHash?:unknown;exitCode?:unknown;frozenInputsUnchanged?:unknown}; + let detail: {requestHash?:unknown;exitCode?:unknown;frozenInputsUnchanged?:unknown;authorityVersion?:unknown;rootAuthority?:unknown;detail?:unknown}; try {detail=JSON.parse(evidence.detail??"");} catch {return false;} + let authorityVersion = detail.authorityVersion; + let rootAuthority = detail.rootAuthority; + if(detail.detail) { + try { + const inner = typeof detail.detail === "string" ? JSON.parse(detail.detail) : detail.detail; + if(inner && typeof inner === "object") { + authorityVersion = authorityVersion ?? (inner as Record).authorityVersion; + rootAuthority = rootAuthority ?? (inner as Record).rootAuthority; + } + } catch {} + } + if(lease.grant.coordinatorThread !== binding.root.id) return false; + if(authorityVersion !== undefined && authorityVersion !== binding.generation) return false; + if(rootAuthority !== undefined && rootAuthority !== binding.root.id) return false; + if(binding.row.parent_id !== null && (!authorityVersion || !rootAuthority)) return false; + const witness=this.terminal(evidence.operationHandle); return !!witness && witness.lease_id===lease.leaseId && lease.ownerThread===owner.ownerThread && evidence.operationHandle===lease.operationHandle && witness.request_hash===detail.requestHash && witness.exit_code===detail.exitCode && (witness.frozen_inputs_unchanged===1)===detail.frozenInputsUnchanged && @@ -260,9 +306,35 @@ export class CarrierBindingStore { return this.database.sqlite.transaction(()=>{ const parent=this.current(context); if(parent.contract.cutover || contract.cutover || contract.operations.includes("cutover_start")) deny("Cutover authority cannot be delegated"); - if(parent.contract.role!=="controller") deny("Workers cannot delegate or promote themselves"); const child=this.validateContract(contract); if(child.role!=="worker") deny("Delegation cannot create a controller"); + + const parentDepth = contractRemainingDepth(parent.contract); + if(parent.contract.role === "controller") { + if(parentDepth !== undefined) { + if(parentDepth <= 0) deny("Controller delegation depth exceeded"); + const childDepth = contractRemainingDepth(child); + if(childDepth !== undefined && childDepth > parentDepth - 1) { + deny("Child delegation depth exceeds parent remaining depth"); + } + } else { + const childDepth = contractRemainingDepth(child); + if(childDepth !== undefined && childDepth > 0) { + deny("Child cannot have delegation depth when parent is unbounded / has no remaining depth"); + } + } + } else if(parent.contract.role === "worker") { + if(parentDepth === undefined || parentDepth <= 0) { + deny("Workers cannot delegate without remaining delegation depth"); + } + const childDepth = contractRemainingDepth(child); + if(childDepth !== undefined && childDepth > parentDepth - 1) { + deny("Child delegation depth exceeds parent remaining depth"); + } + } else { + deny("Unknown role cannot delegate"); + } + this.assertNarrower(parent.contract,child); if(Date.parse(child.expiresAt)>Date.parse(parent.validity.expires_at)) deny("Delegation exceeds current parent validity"); return this.issue(pendingId,child,parent.row.id); @@ -1081,7 +1153,9 @@ export class CarrierBindingStore { revokeDelegation(context: unknown, id: string, expectedVersion: number) { return this.database.sqlite.transaction(()=>{ const parent=this.current(context), child=this.active(id); - if(parent.contract.role!=="controller" || child.row.parent_id!==parent.row.id) deny(); + const isParent = child.row.parent_id === parent.row.id; + const isControllerAncestor = parent.contract.role === "controller" && child.root.id === parent.row.id; + if(!isParent && !isControllerAncestor) deny(); return this.revoke(id,expectedVersion); }).immediate(); } @@ -1091,6 +1165,16 @@ export class CarrierBindingStore { const binding=this.current(context); this.assertSubject(binding.contract,subject); if(binding.contract.cutover && Date.parse(binding.contract.cutover.expiresAt)<=this.now()) deny("Cutover preparation approval expired"); + const existingForOp = this.database.sqlite.prepare("select binding_id, subject_json, lease_id from carrier_effect_bindings where operation_id=?").all(subject.operationId) as Array<{binding_id:string;subject_json:string;lease_id:string}>; + for (const prior of existingForOp) { + if (prior.binding_id !== binding.row.id) { + const priorLease = this.ownership.get(prior.lease_id); + if (!priorLease || priorLease.terminalState === undefined || priorLease.operationHandle !== undefined || priorLease.operationState === "active") { + throw new ControlPlaneOwnershipError("OWNERSHIP_CONFLICT", "Operation is bound to a different delegation chain and requires reconciliation before reassignment"); + } + deny("Operation was previously bound to a different delegation chain"); + } + } const transferred=this.effectLease(binding,subject); if(transferred) return this.ownership.assertHeld(context,transferred.leaseId,transferred.version,subject.operation,subject.baseRevision); const existing=this.database.sqlite.prepare("select subject_json,lease_id from carrier_effect_bindings where binding_id=? and operation_id=?").get(binding.row.id,subject.operationId) as {subject_json:string;lease_id:string}|undefined; @@ -1163,7 +1247,7 @@ export class CarrierBindingStore { return binding; } private active(id: string, seen = new Set(), allowExpiredSelf = false): Binding { - if(seen.has(id) || seen.size>=32) deny("Invalid delegation lineage"); + if(seen.has(id) || seen.size>=MAX_DELEGATION_LINEAGE) deny("Invalid delegation lineage"); seen.add(id); const row=this.database.sqlite.prepare("select * from carrier_bindings where id=?").get(id) as BindingRow|undefined; if(!row || row.revoked!==0 || row.version!==1) deny("Carrier expired or revoked"); @@ -1172,13 +1256,22 @@ export class CarrierBindingStore { const contract=this.validateContract(JSON.parse(row.contract_json) as CarrierContract,false); if(JSON.stringify(contract)!==row.contract_json) deny("Persisted contract is not canonical"); if(row.parent_id) { - const parent=this.active(row.parent_id,seen); - if(parent.contract.role!=="controller" || contract.role!=="worker") deny(); + const parent=this.active(row.parent_id,seen,false); + if(!["controller","worker"].includes(parent.contract.role) || contract.role!=="worker") deny("Invalid delegation role hierarchy"); + if(parent.contract.role === "worker") { + const parentDepth = contractRemainingDepth(parent.contract); + if(parentDepth === undefined || parentDepth <= 0) deny("Worker ancestor lacks delegation authority"); + } this.assertNarrower(parent.contract,contract); if(Date.parse(validity.expires_at)>Date.parse(parent.validity.expires_at)) deny("Child validity exceeds parent"); - return {row,contract,root:parent.root,validity,generation:`${parent.generation}/${id}:${validity.version}`}; + const rootContract = JSON.parse(parent.root.contract_json) as CarrierContract; + if(contract.repository !== rootContract.repository || contract.goal !== rootContract.goal || contract.baseRevision !== rootContract.baseRevision) { + deny("Delegation cross-stitching detected"); + } + return {row,contract,root:parent.root,validity,generation:`${parent.generation}/${id}:${validity.version}`,chain:[...parent.chain,id]}; } - return {row,contract,root:row,validity,generation:`${id}:${validity.version}`}; + if(contract.role !== "controller") deny("Root carrier must be a controller"); + return {row,contract,root:row,validity,generation:`${id}:${validity.version}`,chain:[id]}; } private issue(pendingId: string, input: CarrierContract, parentId: string|null) { const contract=this.validateContract(input); @@ -1213,10 +1306,19 @@ export class CarrierBindingStore { !/^[a-f0-9]{40,64}$/.test(input.baseRevision) || !Array.isArray(input.scope) || input.scope.length<1 || input.scope.length>64 || !Array.isArray(input.operations) || input.operations.length<1 || input.operations.some(op=>op!=="dependency_sync" && op!=="cutover_start") || !Number.isFinite(Date.parse(input.expiresAt)) || (requireFuture && Date.parse(input.expiresAt)<=this.now())) deny("Invalid or expired carrier contract"); + if(input.maxDepth !== undefined && (!Number.isSafeInteger(input.maxDepth) || input.maxDepth < 0 || input.maxDepth > MAX_DELEGATION_DEPTH)) { + deny("Invalid delegation depth"); + } + if(input.remainingDepth !== undefined && (!Number.isSafeInteger(input.remainingDepth) || input.remainingDepth < 0 || input.remainingDepth > MAX_DELEGATION_DEPTH)) { + deny("Invalid remaining delegation depth"); + } + if(input.maxDepth !== undefined && input.remainingDepth !== undefined && input.remainingDepth > input.maxDepth) { + deny("Remaining delegation depth exceeds max depth"); + } let cutover:CarrierCutoverContract|undefined; if(input.operations.includes("cutover_start") || input.cutover!==undefined) { const parsed=cutoverSchema.safeParse(input.cutover); - if(!parsed.success || Object.keys(input).some(key=>!["repository","goal","role","scope","baseRevision","operations","expiresAt","cutover"].includes(key))) deny("Invalid cutover approval"); + if(!parsed.success || Object.keys(input).some(key=>!["repository","goal","role","scope","baseRevision","operations","expiresAt","cutover","maxDepth","remainingDepth"].includes(key))) deny("Invalid cutover approval"); cutover=parsed.data; if(input.role!=="controller" || input.operations.length!==1 || input.operations[0]!=="cutover_start" || input.scope.length!==1 || physical(cutover.stateRoot)!==physical(this.stateDir) || cutover.stateRoot!==physical(cutover.stateRoot) || physical(input.scope[0]!)!==cutover.stateRoot || @@ -1226,12 +1328,29 @@ export class CarrierBindingStore { } return {repository:normalizeRepositoryKey(input.repository),goal:input.goal,role:input.role, scope:[...new Set(input.scope.map(physical))].sort(),baseRevision:input.baseRevision, - operations:[...new Set(input.operations)].sort(),expiresAt:new Date(input.expiresAt).toISOString(),...(cutover?{cutover}:{})}; + operations:[...new Set(input.operations)].sort(),expiresAt:new Date(input.expiresAt).toISOString(), + ...(input.maxDepth !== undefined ? { maxDepth: input.maxDepth } : {}), + ...(input.remainingDepth !== undefined ? { remainingDepth: input.remainingDepth } : {}), + ...(cutover?{cutover}:{})}; } private assertNarrower(parent: CarrierContract, child: CarrierContract) { if(parent.repository!==child.repository || parent.goal!==child.goal || parent.baseRevision!==child.baseRevision || child.operations.some(op=>!parent.operations.includes(op)) || child.scope.some(path=>!parent.scope.some(root=>contains(root,path)))) deny("Delegation exceeds parent scope"); + if(Date.parse(child.expiresAt) > Date.parse(parent.expiresAt)) { + deny("Child expiration exceeds parent expiration"); + } + const parentDepth = contractRemainingDepth(parent); + const childDepth = contractRemainingDepth(child); + if(parent.role === "worker" && (parentDepth === undefined || parentDepth <= 0)) { + deny("Worker cannot have children without remaining depth"); + } + if(parentDepth !== undefined && childDepth !== undefined && childDepth > parentDepth - 1) { + deny("Child delegation depth exceeds parent remaining depth"); + } + if(parentDepth === undefined && childDepth !== undefined && childDepth > 0) { + deny("Child cannot widen delegation depth beyond parent"); + } } private assertSubject(contract: CarrierContract, subject: EffectSubject) { if(!subject || !/^[a-f0-9]{64}$/.test(subject.requestHash) || !/^[A-Za-z0-9._:-]{1,160}$/.test(subject.operationId) || diff --git a/src/chat-swarm-store.test.ts b/src/chat-swarm-store.test.ts index 07072efc3..52dc128f9 100644 --- a/src/chat-swarm-store.test.ts +++ b/src/chat-swarm-store.test.ts @@ -19,7 +19,7 @@ test("RESULT_READY releases worker while retaining immutable result until collec test("restart converts possibly started work to reconciliation but keeps queued work queued", () => { const f = fixture(); let queuedId: string; let runningId: string; try { queuedId = f.store.createTask({ swarmId: f.swarm.id, taskKey: "queued", prompt: "q" }).task.id; runningId = f.store.createTask({ swarmId: f.swarm.id, taskKey: "running", prompt: "r" }).task.id; f.store.claimTask(runningId, f.worker.id); f.store.startTask(runningId, f.worker.id); const attempt = f.store.listAttempts(runningId)[0]!; f.store.close(); f.store = new ChatSwarmStore(f.root); assert.equal(f.store.getTask(runningId)?.lifecycleState, "RUNNING"); f.store.recoverAfterRestart(); assert.equal(f.store.getTask(queuedId)?.lifecycleState, "QUEUED"); assert.equal(f.store.getTask(runningId)?.lifecycleState, "RECONCILE_REQUIRED"); assert.throws(() => f.store.claimTask(runningId, f.worker.id), (e: unknown) => e instanceof ChatSwarmError && e.code === "RECONCILIATION_REQUIRED"); assert.throws(() => f.store.resolveReconciliation(runningId, "REQUEUE"), (e: unknown) => e instanceof ChatSwarmError && e.code === "RECONCILIATION_REQUIRED"); assert.equal(f.store.resolveReconciliation(runningId, "REQUEUE", undefined, { taskId: runningId, attemptId: attempt.id, disposition: "NO_EFFECT", evidenceRef: "restart-probe-1" }).lifecycleState, "QUEUED"); } finally { cleanup(f); } }); -test("migration is idempotent and payload boundaries are enforced", () => { const f = fixture(); try { const sqlite = (f.store as unknown as { sqlite: { prepare: (sql: string) => { get: () => { version: number } } } }).sqlite; assert.equal(sqlite.prepare("select max(version) as version from devspace_schema_migrations").get().version, 21); const prompt = f.store.createTask({ swarmId: f.swarm.id, taskKey: "max", prompt: "x".repeat(MAX_PROMPT_BYTES) }).task; f.store.claimTask(prompt.id, f.worker.id); assert.equal(f.store.submitResult(prompt.id, f.worker.id, "x".repeat(MAX_RESULT_BYTES)).result?.length, MAX_RESULT_BYTES); const overflow = f.store.createTask({ swarmId: f.swarm.id, taskKey: "overflow", prompt: "p" }).task; f.store.claimTask(overflow.id, f.worker.id); assert.throws(() => f.store.submitResult(overflow.id, f.worker.id, "x".repeat(MAX_RESULT_BYTES + 1)), ChatSwarmError); f.store.close(); f.store = new ChatSwarmStore(f.root); f.store.recoverAfterRestart(); assert.equal(f.store.getTask(overflow.id)?.lifecycleState, "RECONCILE_REQUIRED"); } finally { cleanup(f); } }); +test("migration is idempotent and payload boundaries are enforced", () => { const f = fixture(); try { const sqlite = (f.store as unknown as { sqlite: { prepare: (sql: string) => { get: () => { version: number } } } }).sqlite; assert.equal(sqlite.prepare("select max(version) as version from devspace_schema_migrations").get().version, 22); const prompt = f.store.createTask({ swarmId: f.swarm.id, taskKey: "max", prompt: "x".repeat(MAX_PROMPT_BYTES) }).task; f.store.claimTask(prompt.id, f.worker.id); assert.equal(f.store.submitResult(prompt.id, f.worker.id, "x".repeat(MAX_RESULT_BYTES)).result?.length, MAX_RESULT_BYTES); const overflow = f.store.createTask({ swarmId: f.swarm.id, taskKey: "overflow", prompt: "p" }).task; f.store.claimTask(overflow.id, f.worker.id); assert.throws(() => f.store.submitResult(overflow.id, f.worker.id, "x".repeat(MAX_RESULT_BYTES + 1)), ChatSwarmError); f.store.close(); f.store = new ChatSwarmStore(f.root); f.store.recoverAfterRestart(); assert.equal(f.store.getTask(overflow.id)?.lifecycleState, "RECONCILE_REQUIRED"); } finally { cleanup(f); } }); test("unknown persisted task state fails closed", () => { const f = fixture(); try { const sqlite = (f.store as unknown as { sqlite: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } }).sqlite; const task = f.store.createTask({ swarmId: f.swarm.id, taskKey: "corrupt", prompt: "p" }).task; sqlite.prepare("update chat_swarm_tasks set lifecycle_state=? where id=?").run("CORRUPT", task.id); assert.throws(() => f.store.getTask(task.id), (e: unknown) => e instanceof ChatSwarmError && e.code === "INVALID_STATE"); } finally { cleanup(f); } }); diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 96d90b5e9..fb1a97a93 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -61,7 +61,8 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 18, name: "chat-swarm-carrier-operations" }, { version: 19, name: "core-mutation-sessions" }, { version: 20, name: "local-agent-provider-continuity" }, - { version: 21, name: "core-mutation-session-rebinds" }, + { version: 21, name: "core-mutation-caller-rebinds" }, + { version: 22, name: "core-mutation-session-rebinds" }, ]); } finally { database.close();