From 934b7ec7fd50cbb16bebec3da3874f7cb4416526 Mon Sep 17 00:00:00 2001 From: tim8es <108188608+tim8es@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:38:59 +0500 Subject: [PATCH 01/11] test: add RED permission engine regressions --- .github/workflows/permission-engine-tdd.yml | 43 ++ test/runtime/permission-engine.test.ts | 515 ++++++++++++++++++++ 2 files changed, 558 insertions(+) create mode 100644 .github/workflows/permission-engine-tdd.yml create mode 100644 test/runtime/permission-engine.test.ts diff --git a/.github/workflows/permission-engine-tdd.yml b/.github/workflows/permission-engine-tdd.yml new file mode 100644 index 00000000..881f5d39 --- /dev/null +++ b/.github/workflows/permission-engine-tdd.yml @@ -0,0 +1,43 @@ +name: Permission Engine TDD + +on: + push: + branches: + - feature/permission-engine-v0-1 + +permissions: + contents: read + +concurrency: + group: permission-engine-tdd-${{ github.ref }} + cancel-in-progress: true + +jobs: + tdd: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: package.json + package-manager-cache: false + + - name: Install repository pnpm + run: npm install --global "$(node -p "require('./package.json').packageManager")" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Focused permission regressions + run: pnpm exec vitest run test/runtime/permission-engine.test.ts + + - name: Full quality gate + run: pnpm check + + - name: Coverage + run: pnpm test:coverage diff --git a/test/runtime/permission-engine.test.ts b/test/runtime/permission-engine.test.ts new file mode 100644 index 00000000..f8fbd340 --- /dev/null +++ b/test/runtime/permission-engine.test.ts @@ -0,0 +1,515 @@ +import type { PermissionDecision, PermissionRequest } from '@mindrail/contracts'; +import { describe, expect, it } from 'vitest'; + +import { canonicalDomainValidator } from './canonical-domain-validator.ts'; +import { InMemoryControlPlane } from '../../src/runtime/in-memory-control-plane.ts'; +import { RuntimeError } from '../../src/runtime/errors.ts'; + +type RuntimeOptions = ConstructorParameters[0]; + +type Execution = ReturnType; + +function createRuntime( + options: { + workspaceId?: string; + permissionPolicy?: RuntimeOptions['permissionPolicy']; + } = {}, +) { + const sequences = new Map(); + let now = new Date('2026-08-29T12:00:00.000Z'); + const workspaceId = options.workspaceId ?? 'ws-1'; + const runtime = new InMemoryControlPlane({ + workspaceId, + workspaceName: 'Dogfood', + now: () => new Date(now), + idFactory: (kind) => { + const sequence = (sequences.get(kind) ?? 0) + 1; + sequences.set(kind, sequence); + return `${kind}-${sequence}`; + }, + leaseDurationMs: 10 * 60_000, + sessionTimeoutMs: 60_000, + validateCanonicalDomainRecord: canonicalDomainValidator, + ...(options.permissionPolicy === undefined ? {} : { permissionPolicy: options.permissionPolicy }), + }); + + return { + runtime, + advance(ms: number) { + now = new Date(now.getTime() + ms); + }, + }; +} + +function establishExecution(runtime: InMemoryControlPlane) { + const workspaceId = runtime.getWorkspace('ws-1').id; + const agent = runtime.registerAgent({ + workspaceId, + displayName: 'Permission worker', + capabilities: ['code.execute'], + }); + const session = runtime.startSession({ workspaceId, agentId: agent.id }); + const goal = runtime.createGoal({ + workspaceId, + title: 'Permission flow', + objective: 'Exercise deterministic permission authority.', + successCriteria: ['Permission records are auditable.'], + }); + const task = runtime.createTask({ + workspaceId, + goalId: goal.id, + title: 'Request permission', + objective: 'Request one bounded permission.', + acceptanceCriteria: ['Permission authority remains fenced.'], + requiredCapabilities: ['code.execute'], + dependencyTaskIds: [], + }); + const claim = runtime.claimTask({ + workspaceId, + taskId: task.id, + sessionId: session.id, + expectedTaskRevision: task.revision, + }); + return { workspaceId, agent, session, goal, task, claim }; +} + +function requestPermission( + runtime: InMemoryControlPlane, + execution: Execution, + permission: string, +) { + return runtime.requestPermission({ + workspaceId: execution.workspaceId, + taskId: execution.task.id, + sessionId: execution.session.id, + leaseId: execution.claim.lease.id, + fencingToken: execution.claim.lease.fencingToken, + permission, + justification: `Need ${permission} for the current task.`, + }); +} + +function decisionInput( + request: PermissionRequest, + previous: PermissionDecision, + actor: { type: 'human' | 'agent' | 'system'; id: string }, + outcome: 'ALLOW' | 'DENY', +) { + return { + workspaceId: request.workspaceId, + requestId: request.id, + actor, + outcome, + expectedPreviousDecisionId: previous.id, + reasonCode: outcome === 'ALLOW' ? 'human.approved' : 'human.denied', + reason: outcome === 'ALLOW' ? 'Reviewed and approved.' : 'Reviewed and denied.', + }; +} + +function expectRuntimeError(code: RuntimeError['code'], operation: () => unknown): void { + try { + operation(); + throw new Error(`Expected RuntimeError ${code}.`); + } catch (error) { + expect(error).toBeInstanceOf(RuntimeError); + expect((error as RuntimeError).code).toBe(code); + } +} + +describe('permission engine v0.1', () => { + it('rejects stale Session, Lease, and fence authority before permission records are created', () => { + const staleSessionFixture = createRuntime(); + const staleExecution = establishExecution(staleSessionFixture.runtime); + staleSessionFixture.advance(60_000); + + expectRuntimeError('SESSION_NOT_ACTIVE', () => + requestPermission(staleSessionFixture.runtime, staleExecution, 'workspace.read'), + ); + + const replacementSession = staleSessionFixture.runtime.startSession({ + workspaceId: staleExecution.workspaceId, + agentId: staleExecution.agent.id, + }); + const recovered = staleSessionFixture.runtime.claimTask({ + workspaceId: staleExecution.workspaceId, + taskId: staleExecution.task.id, + sessionId: replacementSession.id, + expectedTaskRevision: staleExecution.claim.task.revision, + }); + const firstAfterRecovery = staleSessionFixture.runtime.requestPermission({ + workspaceId: staleExecution.workspaceId, + taskId: staleExecution.task.id, + sessionId: replacementSession.id, + leaseId: recovered.lease.id, + fencingToken: recovered.lease.fencingToken, + permission: 'workspace.read', + justification: 'Fresh authority should create the first request.', + }); + expect(firstAfterRecovery.request.id).toBe('permission-request-1'); + + const staleLeaseFixture = createRuntime(); + const leaseExecution = establishExecution(staleLeaseFixture.runtime); + staleLeaseFixture.runtime.releaseLease({ + workspaceId: leaseExecution.workspaceId, + taskId: leaseExecution.task.id, + sessionId: leaseExecution.session.id, + leaseId: leaseExecution.claim.lease.id, + fencingToken: leaseExecution.claim.lease.fencingToken, + expectedLeaseRevision: leaseExecution.claim.lease.revision, + }); + expectRuntimeError('LEASE_NOT_ACTIVE', () => + requestPermission(staleLeaseFixture.runtime, leaseExecution, 'workspace.read'), + ); + + const nextSession = staleLeaseFixture.runtime.startSession({ + workspaceId: leaseExecution.workspaceId, + agentId: leaseExecution.agent.id, + }); + const nextClaim = staleLeaseFixture.runtime.claimTask({ + workspaceId: leaseExecution.workspaceId, + taskId: leaseExecution.task.id, + sessionId: nextSession.id, + expectedTaskRevision: leaseExecution.claim.task.revision, + }); + expectRuntimeError('STALE_FENCING_TOKEN', () => + staleLeaseFixture.runtime.requestPermission({ + workspaceId: leaseExecution.workspaceId, + taskId: leaseExecution.task.id, + sessionId: nextSession.id, + leaseId: nextClaim.lease.id, + fencingToken: leaseExecution.claim.lease.fencingToken, + permission: 'workspace.read', + justification: 'Old fencing must not authorize a new request.', + }), + ); + + const firstWithCurrentFence = staleLeaseFixture.runtime.requestPermission({ + workspaceId: leaseExecution.workspaceId, + taskId: leaseExecution.task.id, + sessionId: nextSession.id, + leaseId: nextClaim.lease.id, + fencingToken: nextClaim.lease.fencingToken, + permission: 'workspace.read', + justification: 'Current fencing authority is valid.', + }); + expect(firstWithCurrentFence.request.id).toBe('permission-request-1'); + }); + + it('evaluates deterministic ALLOW, DENY, and HUMAN_REQUIRED paths with exact policy attribution', () => { + const { runtime } = createRuntime(); + const execution = establishExecution(runtime); + + const allowed = requestPermission(runtime, execution, 'workspace.read'); + expect(allowed.decision).toMatchObject({ + sequence: 1, + outcome: 'ALLOW', + basis: 'policy', + decidedBy: { type: 'system', id: 'mindrail.permission-policy' }, + policyRef: { id: 'mindrail.permission', version: '0.1.0' }, + }); + expect(allowed.decision.supersedesDecisionId).toBeUndefined(); + expect( + runtime.isPermissionGrantEffective({ + workspaceId: execution.workspaceId, + requestId: allowed.request.id, + taskId: execution.task.id, + sessionId: execution.session.id, + leaseId: execution.claim.lease.id, + fencingToken: execution.claim.lease.fencingToken, + }), + ).toBe(true); + + const denied = requestPermission(runtime, execution, 'external.publish'); + expect(denied.decision.outcome).toBe('DENY'); + expect( + runtime.isPermissionGrantEffective({ + workspaceId: execution.workspaceId, + requestId: denied.request.id, + taskId: execution.task.id, + sessionId: execution.session.id, + leaseId: execution.claim.lease.id, + fencingToken: execution.claim.lease.fencingToken, + }), + ).toBe(false); + + const humanRequired = requestPermission(runtime, execution, 'repository.write'); + expect(humanRequired.decision.outcome).toBe('HUMAN_REQUIRED'); + expect( + runtime.isPermissionGrantEffective({ + workspaceId: execution.workspaceId, + requestId: humanRequired.request.id, + taskId: execution.task.id, + sessionId: execution.session.id, + leaseId: execution.claim.lease.id, + fencingToken: execution.claim.lease.fencingToken, + }), + ).toBe(false); + }); + + it('allows only human ALLOW or DENY follow-up to the latest HUMAN_REQUIRED decision', () => { + const { runtime } = createRuntime(); + const execution = establishExecution(runtime); + const pending = requestPermission(runtime, execution, 'repository.write'); + + expectRuntimeError('ACTOR_NOT_AUTHORIZED', () => + runtime.recordPermissionDecision( + decisionInput( + pending.request, + pending.decision, + { type: 'agent', id: execution.agent.id }, + 'ALLOW', + ), + ), + ); + expectRuntimeError('ACTOR_NOT_AUTHORIZED', () => + runtime.recordPermissionDecision( + decisionInput( + pending.request, + pending.decision, + { type: 'system', id: 'controller-1' }, + 'DENY', + ), + ), + ); + expectRuntimeError('CONFLICT', () => + runtime.recordPermissionDecision({ + ...decisionInput( + pending.request, + pending.decision, + { type: 'human', id: 'human-1' }, + 'ALLOW', + ), + expectedPreviousDecisionId: 'permission-decision-stale', + }), + ); + expectRuntimeError('INVALID_INPUT', () => + runtime.recordPermissionDecision({ + ...decisionInput( + pending.request, + pending.decision, + { type: 'human', id: 'human-1' }, + 'ALLOW', + ), + outcome: 'HUMAN_REQUIRED', + } as never), + ); + + const approved = runtime.recordPermissionDecision( + decisionInput( + pending.request, + pending.decision, + { type: 'human', id: 'human-1' }, + 'ALLOW', + ), + ); + expect(approved).toMatchObject({ + sequence: 2, + outcome: 'ALLOW', + basis: 'human', + decidedBy: { type: 'human', id: 'human-1' }, + supersedesDecisionId: pending.decision.id, + }); + expect(approved.policyRef).toBeUndefined(); + + expectRuntimeError('INVALID_STATE_TRANSITION', () => + runtime.recordPermissionDecision( + decisionInput(pending.request, approved, { type: 'human', id: 'human-2' }, 'DENY'), + ), + ); + expect(runtime.listPermissionDecisions('ws-1', pending.request.id)).toHaveLength(2); + }); + + it('rejects cross-workspace human decision references', () => { + const first = createRuntime(); + const execution = establishExecution(first.runtime); + const pending = requestPermission(first.runtime, execution, 'repository.write'); + + const second = createRuntime({ workspaceId: 'ws-2' }); + expectRuntimeError('NOT_FOUND', () => + second.runtime.recordPermissionDecision({ + workspaceId: 'ws-2', + requestId: pending.request.id, + actor: { type: 'human', id: 'human-1' }, + outcome: 'ALLOW', + expectedPreviousDecisionId: pending.decision.id, + reasonCode: 'human.approved', + }), + ); + }); + + it('records late human history without reviving or transferring execution authority', () => { + const { runtime } = createRuntime(); + const execution = establishExecution(runtime); + const pending = requestPermission(runtime, execution, 'repository.write'); + + runtime.releaseLease({ + workspaceId: execution.workspaceId, + taskId: execution.task.id, + sessionId: execution.session.id, + leaseId: execution.claim.lease.id, + fencingToken: execution.claim.lease.fencingToken, + expectedLeaseRevision: execution.claim.lease.revision, + }); + const replacementSession = runtime.startSession({ + workspaceId: execution.workspaceId, + agentId: execution.agent.id, + }); + const replacement = runtime.claimTask({ + workspaceId: execution.workspaceId, + taskId: execution.task.id, + sessionId: replacementSession.id, + expectedTaskRevision: execution.claim.task.revision, + }); + + const lateAllow = runtime.recordPermissionDecision( + decisionInput( + pending.request, + pending.decision, + { type: 'human', id: 'human-1' }, + 'ALLOW', + ), + ); + expect(lateAllow.outcome).toBe('ALLOW'); + expect( + runtime.isPermissionGrantEffective({ + workspaceId: execution.workspaceId, + requestId: pending.request.id, + taskId: execution.task.id, + sessionId: execution.session.id, + leaseId: execution.claim.lease.id, + fencingToken: execution.claim.lease.fencingToken, + }), + ).toBe(false); + expect( + runtime.isPermissionGrantEffective({ + workspaceId: execution.workspaceId, + requestId: pending.request.id, + taskId: execution.task.id, + sessionId: replacementSession.id, + leaseId: replacement.lease.id, + fencingToken: replacement.lease.fencingToken, + }), + ).toBe(false); + + const replacementRequest = runtime.requestPermission({ + workspaceId: execution.workspaceId, + taskId: execution.task.id, + sessionId: replacementSession.id, + leaseId: replacement.lease.id, + fencingToken: replacement.lease.fencingToken, + permission: 'repository.write', + justification: 'Replacement execution needs its own request.', + }); + expect(replacementRequest.request.id).not.toBe(pending.request.id); + expect(replacementRequest.decision.outcome).toBe('HUMAN_REQUIRED'); + }); + + it('replays exact RequestPermission protocol retries without duplicate records', () => { + const { runtime } = createRuntime(); + const execution = establishExecution(runtime); + const command = { + protocolVersion: '0.1' as const, + command: 'RequestPermission' as const, + commandId: 'cmd-permission-1', + workspaceId: execution.workspaceId, + actor: { type: 'agent' as const, id: execution.agent.id }, + correlationId: 'corr-first', + taskId: execution.task.id, + sessionId: execution.session.id, + leaseId: execution.claim.lease.id, + fencingToken: execution.claim.lease.fencingToken, + permission: 'workspace.read', + justification: 'Read the current workspace.', + }; + + const first = runtime.execute(command); + if ('error' in first) throw new Error(`Unexpected protocol failure: ${first.error.code}`); + const firstResult = first.result as { + request: PermissionRequest; + decision: PermissionDecision; + }; + expect(first.replayed).toBe(false); + + const replay = runtime.execute({ ...command, correlationId: 'corr-retry' }); + if ('error' in replay) throw new Error(`Unexpected replay failure: ${replay.error.code}`); + const replayResult = replay.result as { + request: PermissionRequest; + decision: PermissionDecision; + }; + expect(replay.replayed).toBe(true); + expect(replay.correlationId).toBe('corr-retry'); + expect(replayResult.request.id).toBe(firstResult.request.id); + expect(replayResult.decision.id).toBe(firstResult.decision.id); + expect(runtime.getPermissionRequest('ws-1', firstResult.request.id)).toEqual(firstResult.request); + expect(runtime.listPermissionDecisions('ws-1', firstResult.request.id)).toEqual([ + firstResult.decision, + ]); + }); + + it('fails closed when deterministic policy evaluation is unavailable and appends nothing', () => { + const failingPolicy = { + ref: { id: 'mindrail.permission', version: 'broken' }, + evaluate: () => { + throw new Error('Policy source unavailable.'); + }, + }; + const { runtime } = createRuntime({ permissionPolicy: failingPolicy }); + const execution = establishExecution(runtime); + const command = { + protocolVersion: '0.1' as const, + command: 'RequestPermission' as const, + commandId: 'cmd-policy-failure', + workspaceId: execution.workspaceId, + actor: { type: 'agent' as const, id: execution.agent.id }, + taskId: execution.task.id, + sessionId: execution.session.id, + leaseId: execution.claim.lease.id, + fencingToken: execution.claim.lease.fencingToken, + permission: 'workspace.read', + justification: 'This must fail closed.', + }; + + const first = runtime.execute(command); + expect('error' in first && first.error.code).toBe('POLICY_UNAVAILABLE'); + expect(first.replayed).toBe(false); + expectRuntimeError('NOT_FOUND', () => + runtime.getPermissionRequest(execution.workspaceId, 'permission-request-1'), + ); + + const replay = runtime.execute(command); + expect('error' in replay && replay.error.code).toBe('POLICY_UNAVAILABLE'); + expect(replay.replayed).toBe(true); + }); + + it('fails closed with DENY when no deterministic rule matches', () => { + const { runtime } = createRuntime(); + const execution = establishExecution(runtime); + const unmatched = requestPermission(runtime, execution, 'unknown.permission'); + + expect(unmatched.decision).toMatchObject({ + outcome: 'DENY', + reasonCode: 'policy.no_matching_rule', + }); + }); + + it('canonical-validates every emitted PermissionRequest and PermissionDecision', () => { + const { runtime } = createRuntime(); + const execution = establishExecution(runtime); + const allowed = requestPermission(runtime, execution, 'workspace.read'); + const pending = requestPermission(runtime, execution, 'repository.write'); + const deniedByHuman = runtime.recordPermissionDecision( + decisionInput( + pending.request, + pending.decision, + { type: 'human', id: 'human-1' }, + 'DENY', + ), + ); + + expect(canonicalDomainValidator('PermissionRequest', allowed.request).valid).toBe(true); + expect(canonicalDomainValidator('PermissionDecision', allowed.decision).valid).toBe(true); + expect(canonicalDomainValidator('PermissionRequest', pending.request).valid).toBe(true); + expect(canonicalDomainValidator('PermissionDecision', pending.decision).valid).toBe(true); + expect(canonicalDomainValidator('PermissionDecision', deniedByHuman).valid).toBe(true); + }); +}); From 811c06df27ca7bb689a34ad5fab094728a6395e6 Mon Sep 17 00:00:00 2001 From: tim8es <108188608+tim8es@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:44:47 +0500 Subject: [PATCH 02/11] chore: stage permission engine implementation --- .../apply-permission-integration.yml | 33 +++ scripts/apply-permission-integration.mjs | 62 ++++ src/policy/permission-policy.ts | 48 ++++ src/runtime/domain-validation.ts | 11 +- src/runtime/errors.ts | 5 +- src/runtime/permission-service.ts | 265 ++++++++++++++++++ src/runtime/protocol-validation.ts | 58 ++++ src/runtime/protocol.ts | 24 +- test/runtime/canonical-domain-validator.ts | 2 + 9 files changed, 505 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/apply-permission-integration.yml create mode 100644 scripts/apply-permission-integration.mjs create mode 100644 src/policy/permission-policy.ts create mode 100644 src/runtime/permission-service.ts diff --git a/.github/workflows/apply-permission-integration.yml b/.github/workflows/apply-permission-integration.yml new file mode 100644 index 00000000..3177b0a8 --- /dev/null +++ b/.github/workflows/apply-permission-integration.yml @@ -0,0 +1,33 @@ +name: Apply Permission Integration + +on: + push: + branches: + - feature/permission-engine-v0-1 + +permissions: + contents: write + +jobs: + apply: + if: github.event.head_commit.message == 'chore: stage permission engine implementation' + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Apply isolated integration patch + run: node scripts/apply-permission-integration.mjs + + - name: Remove temporary integration machinery + run: rm scripts/apply-permission-integration.mjs .github/workflows/apply-permission-integration.yml + + - name: Commit integration + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/runtime/in-memory-control-plane.ts scripts/apply-permission-integration.mjs .github/workflows/apply-permission-integration.yml + git commit -m "feat: integrate permission runtime surface" + git push origin HEAD:feature/permission-engine-v0-1 diff --git a/scripts/apply-permission-integration.mjs b/scripts/apply-permission-integration.mjs new file mode 100644 index 00000000..8df5d847 --- /dev/null +++ b/scripts/apply-permission-integration.mjs @@ -0,0 +1,62 @@ +import { readFileSync, writeFileSync } from 'node:fs'; + +const path = 'src/runtime/in-memory-control-plane.ts'; +let source = readFileSync(path, 'utf8'); + +function replaceOnce(before, after) { + const first = source.indexOf(before); + if (first < 0) { + throw new Error(`Expected integration anchor not found:\n${before}`); + } + if (source.indexOf(before, first + before.length) >= 0) { + throw new Error(`Integration anchor is not unique:\n${before}`); + } + source = `${source.slice(0, first)}${after}${source.slice(first + before.length)}`; +} + +replaceOnce( + ` Lease,\n Reason,\n Session,`, + ` Lease,\n PermissionDecision,\n PermissionRequest,\n Reason,\n Session,`, +); + +replaceOnce( + `import type { CanonicalDomainTarget, CanonicalDomainValidator } from './domain-validation.ts';\nimport { RuntimeError } from './errors.ts';`, + `import type { PermissionPolicy } from '../policy/permission-policy.ts';\nimport { permissionPolicyV01 } from '../policy/permission-policy.ts';\nimport type { CanonicalDomainTarget, CanonicalDomainValidator } from './domain-validation.ts';\nimport { RuntimeError } from './errors.ts';\nimport {\n InMemoryPermissionService,\n type PermissionGrantAuthority,\n type RecordPermissionDecisionInput,\n type RequestPermissionInput,\n type RequestPermissionResult,\n} from './permission-service.ts';`, +); + +replaceOnce( + ` type ProtocolFailure,\n type ProtocolResponse,\n type ProtocolSuccess,\n type RecordCheckpointCommand,`, + ` type ProtocolFailure,\n type ProtocolResponse,\n type ProtocolSuccess,\n type RecordPermissionDecisionCommand,\n type RecordCheckpointCommand,\n type RequestPermissionCommand,`, +); + +replaceOnce( + ` validateCanonicalDomainRecord: CanonicalDomainValidator;\n}`, + ` validateCanonicalDomainRecord: CanonicalDomainValidator;\n permissionPolicy?: PermissionPolicy;\n}`, +); + +replaceOnce( + ` private readonly sessionTimeoutMs: number;\n private readonly validateCanonicalDomainRecord: CanonicalDomainValidator;`, + ` private readonly sessionTimeoutMs: number;\n private readonly validateCanonicalDomainRecord: CanonicalDomainValidator;\n private readonly permissionService: InMemoryPermissionService;`, +); + +replaceOnce( + ` this.sessionTimeoutMs = options.sessionTimeoutMs;\n this.validateCanonicalDomainRecord = options.validateCanonicalDomainRecord;\n const timestamp = this.timestamp();`, + ` this.sessionTimeoutMs = options.sessionTimeoutMs;\n this.validateCanonicalDomainRecord = options.validateCanonicalDomainRecord;\n this.permissionService = new InMemoryPermissionService({\n now: this.now,\n idFactory: this.idFactory,\n validateCanonicalDomainRecord: this.validateCanonicalDomainRecord,\n policy: options.permissionPolicy ?? permissionPolicyV01,\n assertExecutionAuthority: (authority) => {\n this.requireExecutorAuthority(authority);\n },\n });\n const timestamp = this.timestamp();`, +); + +replaceOnce( + ` execute(command: FailTaskCommand): ProtocolResponse;\n execute(command: RetryTaskCommand): ProtocolResponse;`, + ` execute(command: FailTaskCommand): ProtocolResponse;\n execute(command: RequestPermissionCommand): ProtocolResponse;\n execute(command: RecordPermissionDecisionCommand): ProtocolResponse;\n execute(command: RetryTaskCommand): ProtocolResponse;`, +); + +replaceOnce( + ` getGoal(workspaceId: string, goalId: string): Goal {`, + ` requestPermission(input: RequestPermissionInput): RequestPermissionResult {\n this.assertWorkspace(input.workspaceId);\n return this.permissionService.requestPermission(input);\n }\n\n recordPermissionDecision(input: RecordPermissionDecisionInput): PermissionDecision {\n this.assertWorkspace(input.workspaceId);\n return this.permissionService.recordPermissionDecision(input);\n }\n\n getPermissionRequest(workspaceId: string, requestId: string): PermissionRequest {\n this.assertWorkspace(workspaceId);\n return this.permissionService.getPermissionRequest(workspaceId, requestId);\n }\n\n listPermissionDecisions(workspaceId: string, requestId: string): PermissionDecision[] {\n this.assertWorkspace(workspaceId);\n return this.permissionService.listPermissionDecisions(workspaceId, requestId);\n }\n\n isPermissionGrantEffective(input: PermissionGrantAuthority): boolean {\n this.assertWorkspace(input.workspaceId);\n return this.permissionService.isPermissionGrantEffective(input);\n }\n\n getGoal(workspaceId: string, goalId: string): Goal {`, +); + +replaceOnce( + ` case 'RetryTask':\n this.assertControllerActor(command);`, + ` case 'RequestPermission':\n return this.requestPermission({\n workspaceId: command.workspaceId,\n taskId: command.taskId,\n sessionId: command.sessionId,\n leaseId: command.leaseId,\n fencingToken: command.fencingToken,\n permission: command.permission,\n justification: command.justification,\n ...(command.resource === undefined ? {} : { resource: command.resource }),\n });\n case 'RecordPermissionDecision':\n return this.recordPermissionDecision({\n workspaceId: command.workspaceId,\n requestId: command.requestId,\n actor: command.actor,\n outcome: command.outcome,\n expectedPreviousDecisionId: command.expectedPreviousDecisionId,\n reasonCode: command.reasonCode,\n ...(command.reason === undefined ? {} : { reason: command.reason }),\n });\n case 'RetryTask':\n this.assertControllerActor(command);`, +); + +writeFileSync(path, source); diff --git a/src/policy/permission-policy.ts b/src/policy/permission-policy.ts new file mode 100644 index 00000000..552ad07a --- /dev/null +++ b/src/policy/permission-policy.ts @@ -0,0 +1,48 @@ +import type { PermissionDecision, PolicyRef, ResourceRef } from '@mindrail/contracts'; + +export interface PermissionPolicyInput { + permission: string; + resource?: ResourceRef; +} + +export interface PermissionPolicyEvaluation { + outcome: PermissionDecision['outcome']; + reasonCode: string; +} + +export interface PermissionPolicy { + readonly ref: PolicyRef; + evaluate(input: Readonly): PermissionPolicyEvaluation; +} + +export const PERMISSION_POLICY_V0_1_REF = { + id: 'mindrail.permission', + version: '0.1.0', +} as const satisfies PolicyRef; + +const DEFAULT_DENY: PermissionPolicyEvaluation = { + outcome: 'DENY', + reasonCode: 'policy.no_matching_rule', +}; + +const RULES: Readonly> = { + 'workspace.read': { + outcome: 'ALLOW', + reasonCode: 'policy.automatic_allow', + }, + 'external.publish': { + outcome: 'DENY', + reasonCode: 'policy.denied', + }, + 'repository.write': { + outcome: 'HUMAN_REQUIRED', + reasonCode: 'policy.human_required', + }, +}; + +export const permissionPolicyV01: PermissionPolicy = { + ref: PERMISSION_POLICY_V0_1_REF, + evaluate(input) { + return { ...(RULES[input.permission] ?? DEFAULT_DENY) }; + }, +}; diff --git a/src/runtime/domain-validation.ts b/src/runtime/domain-validation.ts index 8312dae9..58523136 100644 --- a/src/runtime/domain-validation.ts +++ b/src/runtime/domain-validation.ts @@ -1,5 +1,14 @@ export type CanonicalDomainTarget = - 'Workspace' | 'Agent' | 'Session' | 'Goal' | 'Task' | 'Lease' | 'Checkpoint' | 'Reason'; + | 'Workspace' + | 'Agent' + | 'Session' + | 'Goal' + | 'Task' + | 'Lease' + | 'Checkpoint' + | 'PermissionRequest' + | 'PermissionDecision' + | 'Reason'; export interface CanonicalDomainValidationResult { readonly valid: boolean; diff --git a/src/runtime/errors.ts b/src/runtime/errors.ts index 9a693c9a..a47ee31f 100644 --- a/src/runtime/errors.ts +++ b/src/runtime/errors.ts @@ -10,7 +10,10 @@ export type RuntimeErrorCode = | 'IDEMPOTENCY_CONFLICT' | 'ACTOR_NOT_AUTHORIZED' | 'SESSION_NOT_ACTIVE' - | 'CAPABILITY_MISMATCH'; + | 'CAPABILITY_MISMATCH' + | 'PERMISSION_DENIED' + | 'HUMAN_DECISION_REQUIRED' + | 'POLICY_UNAVAILABLE'; export class RuntimeError extends Error { readonly code: RuntimeErrorCode; diff --git a/src/runtime/permission-service.ts b/src/runtime/permission-service.ts new file mode 100644 index 00000000..a94c5385 --- /dev/null +++ b/src/runtime/permission-service.ts @@ -0,0 +1,265 @@ +import type { + ActorRef, + PermissionDecision, + PermissionRequest, + ResourceRef, +} from '@mindrail/contracts'; + +import type { PermissionPolicy, PermissionPolicyEvaluation } from '../policy/permission-policy.ts'; +import type { CanonicalDomainTarget, CanonicalDomainValidator } from './domain-validation.ts'; +import { RuntimeError } from './errors.ts'; + +const ENTITY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const NAMESPACED_NAME_PATTERN = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/; +const POLICY_SYSTEM_ACTOR = { type: 'system', id: 'mindrail.permission-policy' } as const; + +export interface PermissionExecutionAuthority { + workspaceId: string; + taskId: string; + sessionId: string; + leaseId: string; + fencingToken: number; +} + +export interface RequestPermissionInput extends PermissionExecutionAuthority { + permission: string; + justification: string; + resource?: ResourceRef; +} + +export interface RequestPermissionResult { + request: PermissionRequest; + decision: PermissionDecision; +} + +export interface RecordPermissionDecisionInput { + workspaceId: string; + requestId: string; + actor: ActorRef; + outcome: 'ALLOW' | 'DENY'; + expectedPreviousDecisionId: string; + reasonCode: string; + reason?: string; +} + +export interface PermissionGrantAuthority extends PermissionExecutionAuthority { + requestId: string; +} + +export interface InMemoryPermissionServiceOptions { + now: () => Date; + idFactory: (kind: string) => string; + validateCanonicalDomainRecord: CanonicalDomainValidator; + policy: PermissionPolicy; + assertExecutionAuthority: (authority: PermissionExecutionAuthority) => void; +} + +export class InMemoryPermissionService { + private readonly now: () => Date; + private readonly idFactory: (kind: string) => string; + private readonly validateCanonicalDomainRecord: CanonicalDomainValidator; + private readonly policy: PermissionPolicy; + private readonly assertExecutionAuthority: (authority: PermissionExecutionAuthority) => void; + + private readonly requests = new Map(); + private readonly decisionsByRequest = new Map(); + + constructor(options: InMemoryPermissionServiceOptions) { + this.now = options.now; + this.idFactory = options.idFactory; + this.validateCanonicalDomainRecord = options.validateCanonicalDomainRecord; + this.policy = options.policy; + this.assertExecutionAuthority = options.assertExecutionAuthority; + } + + requestPermission(input: RequestPermissionInput): RequestPermissionResult { + this.assertExecutionAuthority(input); + + const request: PermissionRequest = { + id: this.idFactory('permission-request'), + workspaceId: input.workspaceId, + taskId: input.taskId, + sessionId: input.sessionId, + leaseId: input.leaseId, + fencingToken: input.fencingToken, + createdAt: this.timestamp(), + permission: input.permission, + justification: input.justification, + ...(input.resource === undefined ? {} : { resource: clone(input.resource) }), + }; + this.assertCanonical('PermissionRequest', request); + + const evaluation = this.evaluatePolicy(request); + const decision: PermissionDecision = { + id: this.idFactory('permission-decision'), + workspaceId: request.workspaceId, + requestId: request.id, + createdAt: this.timestamp(), + sequence: 1, + outcome: evaluation.outcome, + basis: 'policy', + decidedBy: POLICY_SYSTEM_ACTOR, + reasonCode: evaluation.reasonCode, + policyRef: clone(this.policy.ref), + }; + this.assertCanonical('PermissionDecision', decision); + + this.requests.set(request.id, request); + this.decisionsByRequest.set(request.id, [decision]); + return { request: clone(request), decision: clone(decision) }; + } + + recordPermissionDecision(input: RecordPermissionDecisionInput): PermissionDecision { + const request = this.requireRequest(input.workspaceId, input.requestId); + if (input.actor.type !== 'human') { + throw new RuntimeError( + 'ACTOR_NOT_AUTHORIZED', + `Actor ${input.actor.type}:${input.actor.id} cannot record a human permission decision.`, + ); + } + if (input.outcome !== 'ALLOW' && input.outcome !== 'DENY') { + throw new RuntimeError('INVALID_INPUT', 'Human permission outcome must be ALLOW or DENY.'); + } + + const decisions = this.decisionsByRequest.get(request.id); + const latest = decisions?.at(-1); + if (!decisions || !latest) { + throw new RuntimeError('CONFLICT', `PermissionRequest ${request.id} has no policy decision.`); + } + if (latest.id !== input.expectedPreviousDecisionId) { + throw new RuntimeError( + 'CONFLICT', + `PermissionDecision ${input.expectedPreviousDecisionId} is not the latest predecessor.`, + ); + } + if (latest.outcome !== 'HUMAN_REQUIRED') { + throw new RuntimeError( + 'INVALID_STATE_TRANSITION', + `PermissionRequest ${request.id} is not awaiting a human decision.`, + ); + } + + const decision: PermissionDecision = { + id: this.idFactory('permission-decision'), + workspaceId: request.workspaceId, + requestId: request.id, + createdAt: this.timestamp(), + sequence: latest.sequence + 1, + outcome: input.outcome, + basis: 'human', + decidedBy: clone(input.actor), + reasonCode: input.reasonCode, + ...(input.reason === undefined ? {} : { reason: input.reason }), + supersedesDecisionId: latest.id, + }; + this.assertCanonical('PermissionDecision', decision); + decisions.push(decision); + return clone(decision); + } + + getPermissionRequest(workspaceId: string, requestId: string): PermissionRequest { + return clone(this.requireRequest(workspaceId, requestId)); + } + + listPermissionDecisions(workspaceId: string, requestId: string): PermissionDecision[] { + const request = this.requireRequest(workspaceId, requestId); + return clone(this.decisionsByRequest.get(request.id) ?? []); + } + + isPermissionGrantEffective(input: PermissionGrantAuthority): boolean { + const request = this.requireRequest(input.workspaceId, input.requestId); + if ( + request.taskId !== input.taskId || + request.sessionId !== input.sessionId || + request.leaseId !== input.leaseId || + request.fencingToken !== input.fencingToken + ) { + return false; + } + + const latest = this.decisionsByRequest.get(request.id)?.at(-1); + if (latest?.outcome !== 'ALLOW') { + return false; + } + + try { + this.assertExecutionAuthority(input); + return true; + } catch (error) { + if (error instanceof RuntimeError) { + return false; + } + throw error; + } + } + + private evaluatePolicy(request: PermissionRequest): PermissionPolicyEvaluation { + try { + if (!isPolicyRef(this.policy.ref)) { + throw new Error('PolicyRef is invalid.'); + } + const evaluation = this.policy.evaluate({ + permission: request.permission, + ...(request.resource === undefined ? {} : { resource: clone(request.resource) }), + }); + if (!isPolicyEvaluation(evaluation)) { + throw new Error('Policy evaluation result is invalid.'); + } + return { outcome: evaluation.outcome, reasonCode: evaluation.reasonCode }; + } catch { + throw new RuntimeError( + 'POLICY_UNAVAILABLE', + 'Deterministic permission policy evaluation is unavailable or invalid.', + ); + } + } + + private requireRequest(workspaceId: string, requestId: string): PermissionRequest { + const request = this.requests.get(requestId); + if (!request || request.workspaceId !== workspaceId) { + throw new RuntimeError('NOT_FOUND', `PermissionRequest ${requestId} was not found.`); + } + return request; + } + + private assertCanonical(target: CanonicalDomainTarget, value: unknown): void { + const validation = this.validateCanonicalDomainRecord(target, value); + if (validation.valid) { + return; + } + const details = validation.errors?.slice(0, 3).join('; '); + const message = + details === undefined || details.length === 0 + ? `${target} violates canonical domain schema.` + : `${target} violates canonical domain schema. ${details}`; + throw new RuntimeError('INVALID_INPUT', message); + } + + private timestamp(): string { + return this.now().toISOString(); + } +} + +function isPolicyRef(value: PermissionPolicy['ref']): boolean { + return ( + ENTITY_ID_PATTERN.test(value.id) && + typeof value.version === 'string' && + value.version.length >= 1 && + value.version.length <= 128 + ); +} + +function isPolicyEvaluation(value: PermissionPolicyEvaluation): boolean { + return ( + (value.outcome === 'ALLOW' || + value.outcome === 'DENY' || + value.outcome === 'HUMAN_REQUIRED') && + typeof value.reasonCode === 'string' && + NAMESPACED_NAME_PATTERN.test(value.reasonCode) && + value.reasonCode.length <= 128 + ); +} + +function clone(value: T): T { + return structuredClone(value); +} diff --git a/src/runtime/protocol-validation.ts b/src/runtime/protocol-validation.ts index 01beaca2..2a74e8fa 100644 --- a/src/runtime/protocol-validation.ts +++ b/src/runtime/protocol-validation.ts @@ -6,6 +6,7 @@ export interface ProtocolValidationResult { } const ENTITY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const NAMESPACED_NAME_PATTERN = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/; const COMMANDS = new Set([ 'CreateGoal', 'CreateTask', @@ -13,6 +14,8 @@ const COMMANDS = new Set([ 'RecordCheckpoint', 'CompleteTask', 'FailTask', + 'RequestPermission', + 'RecordPermissionDecision', 'RetryTask', 'CancelTask', 'CancelGoal', @@ -92,6 +95,21 @@ function validateCommandFields( requireString(command, 'summary', errors); requireEvidenceArray(command, 'evidence', errors); return; + case 'RequestPermission': + validateExecutorFields(command, errors); + requireNamespacedName(command, 'permission', errors); + requireString(command, 'justification', errors); + optionalResource(command, 'resource', errors); + return; + case 'RecordPermissionDecision': + requireEntityId(command, 'requestId', errors); + if (command.outcome !== 'ALLOW' && command.outcome !== 'DENY') { + errors.push('outcome must be ALLOW or DENY'); + } + requireEntityId(command, 'expectedPreviousDecisionId', errors); + requireNamespacedName(command, 'reasonCode', errors); + optionalString(command, 'reason', errors); + return; case 'RetryTask': requireEntityId(command, 'taskId', errors); requirePositiveInteger(command, 'expectedTaskRevision', errors); @@ -152,6 +170,27 @@ function requireString(record: Record, key: string, errors: str if (typeof record[key] !== 'string') errors.push(`${key} must be a string`); } +function optionalString(record: Record, key: string, errors: string[]): void { + if (record[key] !== undefined && typeof record[key] !== 'string') { + errors.push(`${key} must be a string when present`); + } +} + +function requireNamespacedName( + record: Record, + key: string, + errors: string[], +): void { + const value = record[key]; + if ( + typeof value !== 'string' || + value.length > 128 || + !NAMESPACED_NAME_PATTERN.test(value) + ) { + errors.push(`${key} must be a NamespacedName`); + } +} + function requireStringArray(record: Record, key: string, errors: string[]): void { const value = record[key]; if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) { @@ -198,6 +237,25 @@ function requireEvidenceArray( } } +function optionalResource(record: Record, key: string, errors: string[]): void { + const value = record[key]; + if (value === undefined) { + return; + } + if (!isRecord(value)) { + errors.push(`${key} must be a ResourceRef-shaped object when present`); + return; + } + if ( + typeof value.type !== 'string' || + value.type.length > 128 || + !NAMESPACED_NAME_PATTERN.test(value.type) || + !isProtocolEntityId(value.id) + ) { + errors.push(`${key} must be a ResourceRef-shaped object when present`); + } +} + function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } diff --git a/src/runtime/protocol.ts b/src/runtime/protocol.ts index 021b51eb..b11d0f95 100644 --- a/src/runtime/protocol.ts +++ b/src/runtime/protocol.ts @@ -1,4 +1,4 @@ -import type { ActorRef, EvidenceRef, Reason } from '@mindrail/contracts'; +import type { ActorRef, EvidenceRef, Reason, ResourceRef } from '@mindrail/contracts'; import type { RuntimeErrorCode } from './errors.ts'; @@ -70,6 +70,26 @@ export interface FailTaskCommand extends CommandEnvelope { evidence: EvidenceRef[]; } +export interface RequestPermissionCommand extends CommandEnvelope { + command: 'RequestPermission'; + taskId: string; + sessionId: string; + leaseId: string; + fencingToken: number; + permission: string; + justification: string; + resource?: ResourceRef; +} + +export interface RecordPermissionDecisionCommand extends CommandEnvelope { + command: 'RecordPermissionDecision'; + requestId: string; + outcome: 'ALLOW' | 'DENY'; + expectedPreviousDecisionId: string; + reasonCode: string; + reason?: string; +} + export interface RetryTaskCommand extends CommandEnvelope { command: 'RetryTask'; taskId: string; @@ -97,6 +117,8 @@ export type ProtocolCommand = | RecordCheckpointCommand | CompleteTaskCommand | FailTaskCommand + | RequestPermissionCommand + | RecordPermissionDecisionCommand | RetryTaskCommand | CancelTaskCommand | CancelGoalCommand; diff --git a/test/runtime/canonical-domain-validator.ts b/test/runtime/canonical-domain-validator.ts index 9cbf3624..1e1e24e5 100644 --- a/test/runtime/canonical-domain-validator.ts +++ b/test/runtime/canonical-domain-validator.ts @@ -25,6 +25,8 @@ const schemaRefs: Record = { Task: 'urn:mindrail:schema:domain:v1:task', Lease: 'urn:mindrail:schema:domain:v1:lease', Checkpoint: 'urn:mindrail:schema:domain:v1:checkpoint', + PermissionRequest: 'urn:mindrail:schema:domain:v1:permission-request', + PermissionDecision: 'urn:mindrail:schema:domain:v1:permission-decision', Reason: 'urn:mindrail:schema:domain:v1:common#/$defs/Reason', }; From 1f76b42055fb0ccd50e1b4a943c35af39f6ca329 Mon Sep 17 00:00:00 2001 From: tim8es <108188608+tim8es@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:45:26 +0500 Subject: [PATCH 03/11] chore: repair one-shot permission integration --- .github/workflows/apply-permission-integration.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/apply-permission-integration.yml b/.github/workflows/apply-permission-integration.yml index 3177b0a8..28058b55 100644 --- a/.github/workflows/apply-permission-integration.yml +++ b/.github/workflows/apply-permission-integration.yml @@ -10,7 +10,6 @@ permissions: jobs: apply: - if: github.event.head_commit.message == 'chore: stage permission engine implementation' runs-on: ubuntu-latest timeout-minutes: 5 From 407e3e76e831ab67f99c04a9cd9fe20f0e25b966 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:45:33 +0000 Subject: [PATCH 04/11] feat: integrate permission runtime surface --- .../apply-permission-integration.yml | 32 --------- scripts/apply-permission-integration.mjs | 62 ---------------- src/runtime/in-memory-control-plane.ts | 72 +++++++++++++++++++ 3 files changed, 72 insertions(+), 94 deletions(-) delete mode 100644 .github/workflows/apply-permission-integration.yml delete mode 100644 scripts/apply-permission-integration.mjs diff --git a/.github/workflows/apply-permission-integration.yml b/.github/workflows/apply-permission-integration.yml deleted file mode 100644 index 28058b55..00000000 --- a/.github/workflows/apply-permission-integration.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Apply Permission Integration - -on: - push: - branches: - - feature/permission-engine-v0-1 - -permissions: - contents: write - -jobs: - apply: - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Apply isolated integration patch - run: node scripts/apply-permission-integration.mjs - - - name: Remove temporary integration machinery - run: rm scripts/apply-permission-integration.mjs .github/workflows/apply-permission-integration.yml - - - name: Commit integration - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/runtime/in-memory-control-plane.ts scripts/apply-permission-integration.mjs .github/workflows/apply-permission-integration.yml - git commit -m "feat: integrate permission runtime surface" - git push origin HEAD:feature/permission-engine-v0-1 diff --git a/scripts/apply-permission-integration.mjs b/scripts/apply-permission-integration.mjs deleted file mode 100644 index 8df5d847..00000000 --- a/scripts/apply-permission-integration.mjs +++ /dev/null @@ -1,62 +0,0 @@ -import { readFileSync, writeFileSync } from 'node:fs'; - -const path = 'src/runtime/in-memory-control-plane.ts'; -let source = readFileSync(path, 'utf8'); - -function replaceOnce(before, after) { - const first = source.indexOf(before); - if (first < 0) { - throw new Error(`Expected integration anchor not found:\n${before}`); - } - if (source.indexOf(before, first + before.length) >= 0) { - throw new Error(`Integration anchor is not unique:\n${before}`); - } - source = `${source.slice(0, first)}${after}${source.slice(first + before.length)}`; -} - -replaceOnce( - ` Lease,\n Reason,\n Session,`, - ` Lease,\n PermissionDecision,\n PermissionRequest,\n Reason,\n Session,`, -); - -replaceOnce( - `import type { CanonicalDomainTarget, CanonicalDomainValidator } from './domain-validation.ts';\nimport { RuntimeError } from './errors.ts';`, - `import type { PermissionPolicy } from '../policy/permission-policy.ts';\nimport { permissionPolicyV01 } from '../policy/permission-policy.ts';\nimport type { CanonicalDomainTarget, CanonicalDomainValidator } from './domain-validation.ts';\nimport { RuntimeError } from './errors.ts';\nimport {\n InMemoryPermissionService,\n type PermissionGrantAuthority,\n type RecordPermissionDecisionInput,\n type RequestPermissionInput,\n type RequestPermissionResult,\n} from './permission-service.ts';`, -); - -replaceOnce( - ` type ProtocolFailure,\n type ProtocolResponse,\n type ProtocolSuccess,\n type RecordCheckpointCommand,`, - ` type ProtocolFailure,\n type ProtocolResponse,\n type ProtocolSuccess,\n type RecordPermissionDecisionCommand,\n type RecordCheckpointCommand,\n type RequestPermissionCommand,`, -); - -replaceOnce( - ` validateCanonicalDomainRecord: CanonicalDomainValidator;\n}`, - ` validateCanonicalDomainRecord: CanonicalDomainValidator;\n permissionPolicy?: PermissionPolicy;\n}`, -); - -replaceOnce( - ` private readonly sessionTimeoutMs: number;\n private readonly validateCanonicalDomainRecord: CanonicalDomainValidator;`, - ` private readonly sessionTimeoutMs: number;\n private readonly validateCanonicalDomainRecord: CanonicalDomainValidator;\n private readonly permissionService: InMemoryPermissionService;`, -); - -replaceOnce( - ` this.sessionTimeoutMs = options.sessionTimeoutMs;\n this.validateCanonicalDomainRecord = options.validateCanonicalDomainRecord;\n const timestamp = this.timestamp();`, - ` this.sessionTimeoutMs = options.sessionTimeoutMs;\n this.validateCanonicalDomainRecord = options.validateCanonicalDomainRecord;\n this.permissionService = new InMemoryPermissionService({\n now: this.now,\n idFactory: this.idFactory,\n validateCanonicalDomainRecord: this.validateCanonicalDomainRecord,\n policy: options.permissionPolicy ?? permissionPolicyV01,\n assertExecutionAuthority: (authority) => {\n this.requireExecutorAuthority(authority);\n },\n });\n const timestamp = this.timestamp();`, -); - -replaceOnce( - ` execute(command: FailTaskCommand): ProtocolResponse;\n execute(command: RetryTaskCommand): ProtocolResponse;`, - ` execute(command: FailTaskCommand): ProtocolResponse;\n execute(command: RequestPermissionCommand): ProtocolResponse;\n execute(command: RecordPermissionDecisionCommand): ProtocolResponse;\n execute(command: RetryTaskCommand): ProtocolResponse;`, -); - -replaceOnce( - ` getGoal(workspaceId: string, goalId: string): Goal {`, - ` requestPermission(input: RequestPermissionInput): RequestPermissionResult {\n this.assertWorkspace(input.workspaceId);\n return this.permissionService.requestPermission(input);\n }\n\n recordPermissionDecision(input: RecordPermissionDecisionInput): PermissionDecision {\n this.assertWorkspace(input.workspaceId);\n return this.permissionService.recordPermissionDecision(input);\n }\n\n getPermissionRequest(workspaceId: string, requestId: string): PermissionRequest {\n this.assertWorkspace(workspaceId);\n return this.permissionService.getPermissionRequest(workspaceId, requestId);\n }\n\n listPermissionDecisions(workspaceId: string, requestId: string): PermissionDecision[] {\n this.assertWorkspace(workspaceId);\n return this.permissionService.listPermissionDecisions(workspaceId, requestId);\n }\n\n isPermissionGrantEffective(input: PermissionGrantAuthority): boolean {\n this.assertWorkspace(input.workspaceId);\n return this.permissionService.isPermissionGrantEffective(input);\n }\n\n getGoal(workspaceId: string, goalId: string): Goal {`, -); - -replaceOnce( - ` case 'RetryTask':\n this.assertControllerActor(command);`, - ` case 'RequestPermission':\n return this.requestPermission({\n workspaceId: command.workspaceId,\n taskId: command.taskId,\n sessionId: command.sessionId,\n leaseId: command.leaseId,\n fencingToken: command.fencingToken,\n permission: command.permission,\n justification: command.justification,\n ...(command.resource === undefined ? {} : { resource: command.resource }),\n });\n case 'RecordPermissionDecision':\n return this.recordPermissionDecision({\n workspaceId: command.workspaceId,\n requestId: command.requestId,\n actor: command.actor,\n outcome: command.outcome,\n expectedPreviousDecisionId: command.expectedPreviousDecisionId,\n reasonCode: command.reasonCode,\n ...(command.reason === undefined ? {} : { reason: command.reason }),\n });\n case 'RetryTask':\n this.assertControllerActor(command);`, -); - -writeFileSync(path, source); diff --git a/src/runtime/in-memory-control-plane.ts b/src/runtime/in-memory-control-plane.ts index d1665023..ceac2dc8 100644 --- a/src/runtime/in-memory-control-plane.ts +++ b/src/runtime/in-memory-control-plane.ts @@ -4,14 +4,25 @@ import type { EvidenceRef, Goal, Lease, + PermissionDecision, + PermissionRequest, Reason, Session, Task, Workspace, } from '@mindrail/contracts'; +import type { PermissionPolicy } from '../policy/permission-policy.ts'; +import { permissionPolicyV01 } from '../policy/permission-policy.ts'; import type { CanonicalDomainTarget, CanonicalDomainValidator } from './domain-validation.ts'; import { RuntimeError } from './errors.ts'; +import { + InMemoryPermissionService, + type PermissionGrantAuthority, + type RecordPermissionDecisionInput, + type RequestPermissionInput, + type RequestPermissionResult, +} from './permission-service.ts'; import { semanticFingerprint, type CancelGoalCommand, @@ -25,7 +36,9 @@ import { type ProtocolFailure, type ProtocolResponse, type ProtocolSuccess, + type RecordPermissionDecisionCommand, type RecordCheckpointCommand, + type RequestPermissionCommand, type RetryTaskCommand, } from './protocol.ts'; import { isProtocolEntityId, validateProtocolCommand } from './protocol-validation.ts'; @@ -38,6 +51,7 @@ export interface InMemoryControlPlaneOptions { leaseDurationMs: number; sessionTimeoutMs: number; validateCanonicalDomainRecord: CanonicalDomainValidator; + permissionPolicy?: PermissionPolicy; } export interface RegisterAgentInput { @@ -167,6 +181,7 @@ export class InMemoryControlPlane { private readonly leaseDurationMs: number; private readonly sessionTimeoutMs: number; private readonly validateCanonicalDomainRecord: CanonicalDomainValidator; + private readonly permissionService: InMemoryPermissionService; private readonly agents = new Map(); private readonly sessions = new Map(); @@ -191,6 +206,15 @@ export class InMemoryControlPlane { this.leaseDurationMs = options.leaseDurationMs; this.sessionTimeoutMs = options.sessionTimeoutMs; this.validateCanonicalDomainRecord = options.validateCanonicalDomainRecord; + this.permissionService = new InMemoryPermissionService({ + now: this.now, + idFactory: this.idFactory, + validateCanonicalDomainRecord: this.validateCanonicalDomainRecord, + policy: options.permissionPolicy ?? permissionPolicyV01, + assertExecutionAuthority: (authority) => { + this.requireExecutorAuthority(authority); + }, + }); const timestamp = this.timestamp(); const workspace: Workspace = { id: options.workspaceId, @@ -210,6 +234,8 @@ export class InMemoryControlPlane { execute(command: RecordCheckpointCommand): ProtocolResponse; execute(command: CompleteTaskCommand): ProtocolResponse; execute(command: FailTaskCommand): ProtocolResponse; + execute(command: RequestPermissionCommand): ProtocolResponse; + execute(command: RecordPermissionDecisionCommand): ProtocolResponse; execute(command: RetryTaskCommand): ProtocolResponse; execute(command: CancelTaskCommand): ProtocolResponse; execute(command: CancelGoalCommand): ProtocolResponse; @@ -630,6 +656,31 @@ export class InMemoryControlPlane { }; } + requestPermission(input: RequestPermissionInput): RequestPermissionResult { + this.assertWorkspace(input.workspaceId); + return this.permissionService.requestPermission(input); + } + + recordPermissionDecision(input: RecordPermissionDecisionInput): PermissionDecision { + this.assertWorkspace(input.workspaceId); + return this.permissionService.recordPermissionDecision(input); + } + + getPermissionRequest(workspaceId: string, requestId: string): PermissionRequest { + this.assertWorkspace(workspaceId); + return this.permissionService.getPermissionRequest(workspaceId, requestId); + } + + listPermissionDecisions(workspaceId: string, requestId: string): PermissionDecision[] { + this.assertWorkspace(workspaceId); + return this.permissionService.listPermissionDecisions(workspaceId, requestId); + } + + isPermissionGrantEffective(input: PermissionGrantAuthority): boolean { + this.assertWorkspace(input.workspaceId); + return this.permissionService.isPermissionGrantEffective(input); + } + getGoal(workspaceId: string, goalId: string): Goal { return clone(this.requireGoal(workspaceId, goalId)); } @@ -716,6 +767,27 @@ export class InMemoryControlPlane { summary: command.summary, evidence: command.evidence, }); + case 'RequestPermission': + return this.requestPermission({ + workspaceId: command.workspaceId, + taskId: command.taskId, + sessionId: command.sessionId, + leaseId: command.leaseId, + fencingToken: command.fencingToken, + permission: command.permission, + justification: command.justification, + ...(command.resource === undefined ? {} : { resource: command.resource }), + }); + case 'RecordPermissionDecision': + return this.recordPermissionDecision({ + workspaceId: command.workspaceId, + requestId: command.requestId, + actor: command.actor, + outcome: command.outcome, + expectedPreviousDecisionId: command.expectedPreviousDecisionId, + reasonCode: command.reasonCode, + ...(command.reason === undefined ? {} : { reason: command.reason }), + }); case 'RetryTask': this.assertControllerActor(command); return this.retryTask({ From 08fd27da585b2d4de0758d2379bb24d74975dcbf Mon Sep 17 00:00:00 2001 From: tim8es <108188608+tim8es@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:46:57 +0500 Subject: [PATCH 05/11] docs: record permission engine v0.1 semantics --- docs/CURRENT_STATE.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index e4eeef74..e850eac7 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -15,16 +15,19 @@ The following facts are supported by repository state and executed GitHub Action - `@mindrail/contracts` provides deterministic generated TypeScript bindings. Generated drift, strict schema validation, fixtures, schema invariants, formatting, lint, and TypeScript checks are part of the repository quality gate. - The Cloudflare reference persistence mapping is documented under `docs/architecture/02_CLOUDFLARE_RUNTIME_PERSISTENCE.md`; it is a design, not a deployed persistence implementation. - A deterministic **in-memory local reference runtime vertical slice** exists under `src/runtime/` and consumes the canonical contracts package rather than redefining domain records. -- The runtime requires a `CanonicalDomainValidator` admission seam. Workspace, Agent, Session, Goal, Task, Lease, and Checkpoint records are validated before authoritative insertion; external `Reason` values used by fail/cancel transitions are validated before state mutation. Reference tests wire this seam to the actual strict Draft 2020-12 schemas through the repository's existing dev-only Ajv tooling, so no third-party runtime dependency was added. -- The local runtime currently supports direct Workspace bootstrap plus Agent registration, Session start, Goal/Task creation, Task claim/release/recovery, Checkpoints, Task completion/failure/retry/cancellation, Goal cancellation, dependency release, and automatic Goal success when all Tasks succeed. +- The runtime requires a `CanonicalDomainValidator` admission seam. Workspace, Agent, Session, Goal, Task, Lease, Checkpoint, PermissionRequest, and PermissionDecision records are validated before authoritative insertion; external `Reason` values used by fail/cancel transitions are validated before state mutation. Reference tests wire this seam to the actual strict Draft 2020-12 schemas through the repository's existing dev-only Ajv tooling, so no third-party runtime dependency was added. +- The local runtime currently supports direct Workspace bootstrap plus Agent registration, Session start, Goal/Task creation, Task claim/release/recovery, Checkpoints, Task completion/failure/retry/cancellation, Goal cancellation, dependency release, automatic Goal success when all Tasks succeed, deterministic permission requests, and human permission follow-up decisions. - Session authority now has an explicit configurable `sessionTimeoutMs` policy using authoritative server time and the ADR-0004 half-open boundary. A stale Session is materialized as `expired`; its active Leases are revoked and cease to authorize checkpoint/completion or new claims. Durable running Task state remains recoverable by a replacement Session with a higher fence. - Lease authority is separated from Task state. A running Task may temporarily have no effective Lease and may be recovered by a new Session. -- Same-Session duplicate claim returns the current Lease without minting a new fencing token even when a semantic duplicate still carries the pre-claim Task revision. Recovery after Lease release/expiry/session loss grants a strictly higher per-Task fencing token. Rejected Lease admission does not advance the fencing counter. Stale/revoked Lease authority cannot checkpoint or complete work. +- Same-Session duplicate claim returns the current Lease without minting a new fencing token even when a semantic duplicate still carries the pre-claim Task revision. Recovery after Lease release/expiry/session loss grants a strictly higher per-Task fencing token. Rejected Lease admission does not advance the fencing counter. Stale/revoked Lease authority cannot checkpoint, complete work, or create permission requests. - Capability requirements are checked at claim time, mutable operations use expected revisions where defined by the slice, and Task creation is rejected under a terminal Goal. -- The implemented protocol dispatcher covers `CreateGoal`, `CreateTask`, `ClaimTask`, `RecordCheckpoint`, `CompleteTask`, `FailTask`, `RetryTask`, `CancelTask`, and `CancelGoal`. +- The implemented protocol dispatcher covers `CreateGoal`, `CreateTask`, `ClaimTask`, `RecordCheckpoint`, `CompleteTask`, `FailTask`, `RequestPermission`, `RecordPermissionDecision`, `RetryTask`, `CancelTask`, and `CancelGoal`. - Protocol commands pass structural pre-admission validation before workspace admission, semantic fingerprinting, dispatch, or receipt insertion. Invalid protocol version/discriminator, malformed EntityIds/ActorRef, malformed command shapes, and invalid revision/fencing fields return `INVALID_INPUT` without mutation or idempotency-key reservation. Unknown Workspace returns a protocol `NOT_FOUND` error envelope rather than escaping as an exception and is likewise not admitted as a receipt. -- Controller-only protocol commands `RetryTask`, `CancelTask`, and `CancelGoal` admit human/system actors and reject agent actors with `ACTOR_NOT_AUTHORIZED` before mutation. -- Implemented protocol mutations use `(workspaceId, commandId)` as the in-memory idempotency key. Semantic fingerprints exclude `correlationId` and `causationId`; exact replay returns an immutable stored result/error snapshot while reflecting the current retry's correlation id; command-id reuse with different semantic intent returns `IDEMPOTENCY_CONFLICT`. +- Controller-only protocol commands `RetryTask`, `CancelTask`, and `CancelGoal` admit human/system actors and reject agent actors with `ACTOR_NOT_AUTHORIZED` before mutation. `RecordPermissionDecision` is stricter: only a human actor may append a follow-up and the only public follow-up outcomes are `ALLOW` or `DENY`. +- Implemented protocol mutations use `(workspaceId, commandId)` as the in-memory idempotency key. Semantic fingerprints exclude `correlationId` and `causationId`; exact replay returns an immutable stored result/error snapshot while reflecting the current retry's correlation id; command-id reuse with different semantic intent returns `IDEMPOTENCY_CONFLICT`. Exact `RequestPermission` replay therefore returns the original PermissionRequest/PermissionDecision IDs without duplicate records. +- The default deterministic permission policy is `PolicyRef { id: "mindrail.permission", version: "0.1.0" }`. Its explicit v0.1 demonstration rules are `workspace.read -> ALLOW`, `external.publish -> DENY`, and `repository.write -> HUMAN_REQUIRED`; unmatched permissions fail closed to `DENY` with `policy.no_matching_rule`. Policy exceptions or structurally invalid policy state return `POLICY_UNAVAILABLE` and append no permission records. +- Policy decisions are sequence 1, system-authored by `system:mindrail.permission-policy`, carry the exact PolicyRef, and never supersede a predecessor. `HUMAN_REQUIRED` grants nothing. Human follow-up requires the expected latest decision ID and a latest `HUMAN_REQUIRED`, derives basis/sequence/supersession, and rejects stale predecessors, cross-workspace request references, non-human actors, or repeated follow-up after a terminal human decision. +- A MindRail `ALLOW` is effective only for the exact PermissionRequest while its original Task/Session/Lease/fencing authority remains current. A late human decision may complete audit history after authority loss, but it cannot revive the old Lease or transfer the grant to replacement execution authority. The permission engine does not mint credentials or override host, IAM, sandbox, or tool approval. - `CancelTask` revokes its effective Lease when present. `CancelGoal` terminalizes the Goal, cancels its nonterminal Tasks, and revokes their effective Leases; stale completion is rejected afterward. - Root runtime code explicitly links to `@mindrail/contracts` with `workspace:*`; the frozen pnpm lockfile resolves it as the local workspace package. - GitHub Actions full-verification run `33254737970` on commit `57491f9b153a8163b927b0a811edabe4083068cb` passed frozen installation, Prettier, ESLint, strict TypeScript checks, generated-contract drift detection, the complete Vitest suite, `pnpm check`, and `pnpm test:coverage`. @@ -35,19 +38,19 @@ The following facts are supported by repository state and executed GitHub Action ## Implemented but not yet durable / externally integrated - Local runtime correctness is currently in-memory and single-process. It proves state-machine and protocol semantics but does not survive process restart. -- Command receipts, Lease counters, Tasks, Goals, Checkpoints, Sessions, and other runtime state are not persisted yet. +- Command receipts, Lease counters, Tasks, Goals, Checkpoints, Sessions, PermissionRequests, PermissionDecisions, and other runtime state are not persisted yet. - The canonical validator is an injected core boundary; the current executable reference composition proving it against the real schemas lives in test tooling. Production/deployed composition still needs to provide the same canonical validation boundary. - Session timeout is enforced, but public `HeartbeatSession` and `EndSession` commands are not implemented yet; without heartbeat support, a long-lived real client cannot extend Session liveness through the protocol. +- The v0.1 permission policy is intentionally hard-coded and versioned. It is not a policy DSL, IAM system, credential manager, model judge, or arbitrary-code policy runtime. - Goal-level ordering is deterministic inside the synchronous local runtime. The future D1/Durable Objects reference implementation must independently prove the concurrency guarantees from ADR-0004 and the persistence design. - `Quality` is executable and green on verified branches, but issue #3 still tracks enabling the repository-level required merge gate on `main`. ## Next implementation slices -- Complete the remaining protocol/runtime command surface needed for v0.1, including Session heartbeat/end, Lease renewal, block/resume, and permission commands. -- Implement deterministic permission-policy evaluation and human decision handling. +- Complete the remaining protocol/runtime command surface needed for v0.1, including Session heartbeat/end, Lease renewal, and block/resume. - Add a persistence interface and durable local/reference storage implementation with command receipts, audit events, revision/fencing guards, canonical admission, and restart recovery. - Implement the Cloudflare Workers/Durable Objects/D1 reference deployment behind the vendor-neutral runtime interfaces. -- Add transport adapters for HTTP and MCP without changing core lifecycle semantics. +- Add transport adapters for HTTP and MCP without changing core lifecycle or permission semantics. - Add GitHub integration and minimal Codex/ChatGPT/generic agent bootstrap paths. - Add optional human-facing projections only after runtime state is durable. @@ -58,4 +61,4 @@ The following facts are supported by repository state and executed GitHub Action ## Explicit non-capabilities -MindRail does **not yet** provide a production control plane. It does not persist operational state, expose a deployed HTTP/MCP service, issue runtime permissions, run the Cloudflare reference deployment, integrate with real Codex/ChatGPT sessions, or continue agents unattended across process/runtime termination. The current executable milestone is a verified deterministic in-memory control-plane slice. +MindRail does **not yet** provide a production control plane. It does not persist operational state, expose a deployed HTTP/MCP service, issue credentials or external host/IAM authority, run the Cloudflare reference deployment, integrate with real Codex/ChatGPT sessions, or continue agents unattended across process/runtime termination. The current executable milestone is a deterministic in-memory control-plane slice. From 93fa58984a68b301a39c52d385260b6ed0bc7c7c Mon Sep 17 00:00:00 2001 From: tim8es <108188608+tim8es@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:48:56 +0500 Subject: [PATCH 06/11] chore: format permission engine files --- .../workflows/format-permission-engine.yml | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/format-permission-engine.yml diff --git a/.github/workflows/format-permission-engine.yml b/.github/workflows/format-permission-engine.yml new file mode 100644 index 00000000..5de26f80 --- /dev/null +++ b/.github/workflows/format-permission-engine.yml @@ -0,0 +1,44 @@ +name: Format Permission Engine + +on: + push: + branches: + - feature/permission-engine-v0-1 + +permissions: + contents: write + +jobs: + format: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: package.json + package-manager-cache: false + + - name: Install repository pnpm + run: npm install --global "$(node -p "require('./package.json').packageManager")" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Format permission files + run: pnpm exec prettier --write src/runtime/permission-service.ts src/runtime/protocol-validation.ts test/runtime/permission-engine.test.ts + + - name: Remove temporary formatter workflow + run: rm .github/workflows/format-permission-engine.yml + + - name: Commit formatting + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/runtime/permission-service.ts src/runtime/protocol-validation.ts test/runtime/permission-engine.test.ts .github/workflows/format-permission-engine.yml + git commit -m "style: format permission engine implementation" + git push origin HEAD:feature/permission-engine-v0-1 From d65ce2491920cf8cdbf5cdb2699dee2fb0c761a4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:49:13 +0000 Subject: [PATCH 07/11] style: format permission engine implementation --- .../workflows/format-permission-engine.yml | 44 ------------------- src/runtime/permission-service.ts | 4 +- src/runtime/protocol-validation.ts | 6 +-- test/runtime/permission-engine.test.ts | 29 ++++-------- 4 files changed, 11 insertions(+), 72 deletions(-) delete mode 100644 .github/workflows/format-permission-engine.yml diff --git a/.github/workflows/format-permission-engine.yml b/.github/workflows/format-permission-engine.yml deleted file mode 100644 index 5de26f80..00000000 --- a/.github/workflows/format-permission-engine.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Format Permission Engine - -on: - push: - branches: - - feature/permission-engine-v0-1 - -permissions: - contents: write - -jobs: - format: - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Install Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version-file: package.json - package-manager-cache: false - - - name: Install repository pnpm - run: npm install --global "$(node -p "require('./package.json').packageManager")" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Format permission files - run: pnpm exec prettier --write src/runtime/permission-service.ts src/runtime/protocol-validation.ts test/runtime/permission-engine.test.ts - - - name: Remove temporary formatter workflow - run: rm .github/workflows/format-permission-engine.yml - - - name: Commit formatting - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/runtime/permission-service.ts src/runtime/protocol-validation.ts test/runtime/permission-engine.test.ts .github/workflows/format-permission-engine.yml - git commit -m "style: format permission engine implementation" - git push origin HEAD:feature/permission-engine-v0-1 diff --git a/src/runtime/permission-service.ts b/src/runtime/permission-service.ts index a94c5385..5948391b 100644 --- a/src/runtime/permission-service.ts +++ b/src/runtime/permission-service.ts @@ -251,9 +251,7 @@ function isPolicyRef(value: PermissionPolicy['ref']): boolean { function isPolicyEvaluation(value: PermissionPolicyEvaluation): boolean { return ( - (value.outcome === 'ALLOW' || - value.outcome === 'DENY' || - value.outcome === 'HUMAN_REQUIRED') && + (value.outcome === 'ALLOW' || value.outcome === 'DENY' || value.outcome === 'HUMAN_REQUIRED') && typeof value.reasonCode === 'string' && NAMESPACED_NAME_PATTERN.test(value.reasonCode) && value.reasonCode.length <= 128 diff --git a/src/runtime/protocol-validation.ts b/src/runtime/protocol-validation.ts index 2a74e8fa..0bb81576 100644 --- a/src/runtime/protocol-validation.ts +++ b/src/runtime/protocol-validation.ts @@ -182,11 +182,7 @@ function requireNamespacedName( errors: string[], ): void { const value = record[key]; - if ( - typeof value !== 'string' || - value.length > 128 || - !NAMESPACED_NAME_PATTERN.test(value) - ) { + if (typeof value !== 'string' || value.length > 128 || !NAMESPACED_NAME_PATTERN.test(value)) { errors.push(`${key} must be a NamespacedName`); } } diff --git a/test/runtime/permission-engine.test.ts b/test/runtime/permission-engine.test.ts index f8fbd340..ac918d85 100644 --- a/test/runtime/permission-engine.test.ts +++ b/test/runtime/permission-engine.test.ts @@ -30,7 +30,9 @@ function createRuntime( leaseDurationMs: 10 * 60_000, sessionTimeoutMs: 60_000, validateCanonicalDomainRecord: canonicalDomainValidator, - ...(options.permissionPolicy === undefined ? {} : { permissionPolicy: options.permissionPolicy }), + ...(options.permissionPolicy === undefined + ? {} + : { permissionPolicy: options.permissionPolicy }), }); return { @@ -295,12 +297,7 @@ describe('permission engine v0.1', () => { ); const approved = runtime.recordPermissionDecision( - decisionInput( - pending.request, - pending.decision, - { type: 'human', id: 'human-1' }, - 'ALLOW', - ), + decisionInput(pending.request, pending.decision, { type: 'human', id: 'human-1' }, 'ALLOW'), ); expect(approved).toMatchObject({ sequence: 2, @@ -362,12 +359,7 @@ describe('permission engine v0.1', () => { }); const lateAllow = runtime.recordPermissionDecision( - decisionInput( - pending.request, - pending.decision, - { type: 'human', id: 'human-1' }, - 'ALLOW', - ), + decisionInput(pending.request, pending.decision, { type: 'human', id: 'human-1' }, 'ALLOW'), ); expect(lateAllow.outcome).toBe('ALLOW'); expect( @@ -440,7 +432,9 @@ describe('permission engine v0.1', () => { expect(replay.correlationId).toBe('corr-retry'); expect(replayResult.request.id).toBe(firstResult.request.id); expect(replayResult.decision.id).toBe(firstResult.decision.id); - expect(runtime.getPermissionRequest('ws-1', firstResult.request.id)).toEqual(firstResult.request); + expect(runtime.getPermissionRequest('ws-1', firstResult.request.id)).toEqual( + firstResult.request, + ); expect(runtime.listPermissionDecisions('ws-1', firstResult.request.id)).toEqual([ firstResult.decision, ]); @@ -498,12 +492,7 @@ describe('permission engine v0.1', () => { const allowed = requestPermission(runtime, execution, 'workspace.read'); const pending = requestPermission(runtime, execution, 'repository.write'); const deniedByHuman = runtime.recordPermissionDecision( - decisionInput( - pending.request, - pending.decision, - { type: 'human', id: 'human-1' }, - 'DENY', - ), + decisionInput(pending.request, pending.decision, { type: 'human', id: 'human-1' }, 'DENY'), ); expect(canonicalDomainValidator('PermissionRequest', allowed.request).valid).toBe(true); From 427f2febbe1fab638717556beca8e330128e2d87 Mon Sep 17 00:00:00 2001 From: tim8es <108188608+tim8es@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:49:46 +0500 Subject: [PATCH 08/11] ci: include coverage in permanent quality gate --- .github/workflows/quality.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 8dc9c740..08285d62 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -44,3 +44,6 @@ jobs: - name: Quality gate run: pnpm check + + - name: Coverage + run: pnpm test:coverage From 924ec500685fcfc072c057a0376205fe8a6c6245 Mon Sep 17 00:00:00 2001 From: tim8es <108188608+tim8es@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:50:45 +0500 Subject: [PATCH 09/11] test: cover invalid policy state fail-closed --- .../permission-policy-fail-closed.test.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 test/runtime/permission-policy-fail-closed.test.ts diff --git a/test/runtime/permission-policy-fail-closed.test.ts b/test/runtime/permission-policy-fail-closed.test.ts new file mode 100644 index 00000000..41e86c50 --- /dev/null +++ b/test/runtime/permission-policy-fail-closed.test.ts @@ -0,0 +1,73 @@ +import type { PermissionPolicy } from '../../src/policy/permission-policy.ts'; +import { InMemoryPermissionService } from '../../src/runtime/permission-service.ts'; +import { RuntimeError } from '../../src/runtime/errors.ts'; +import { describe, expect, it } from 'vitest'; + +import { canonicalDomainValidator } from './canonical-domain-validator.ts'; + +function createService(policy: PermissionPolicy) { + let requestSequence = 0; + let decisionSequence = 0; + return new InMemoryPermissionService({ + now: () => new Date('2026-08-29T12:00:00.000Z'), + idFactory: (kind) => { + if (kind === 'permission-request') { + requestSequence += 1; + return `permission-request-${requestSequence}`; + } + decisionSequence += 1; + return `permission-decision-${decisionSequence}`; + }, + validateCanonicalDomainRecord: canonicalDomainValidator, + policy, + assertExecutionAuthority: () => undefined, + }); +} + +function request(service: InMemoryPermissionService) { + service.requestPermission({ + workspaceId: 'ws-1', + taskId: 'task-1', + sessionId: 'session-1', + leaseId: 'lease-1', + fencingToken: 1, + permission: 'workspace.read', + justification: 'Invalid policy state must fail closed.', + }); +} + +function expectPolicyUnavailable(operation: () => unknown): void { + try { + operation(); + throw new Error('Expected POLICY_UNAVAILABLE.'); + } catch (error) { + expect(error).toBeInstanceOf(RuntimeError); + expect((error as RuntimeError).code).toBe('POLICY_UNAVAILABLE'); + } +} + +describe('invalid permission policy state', () => { + it('fails closed when PolicyRef is invalid and appends no request', () => { + const service = createService({ + ref: { id: 'invalid policy ref', version: '' }, + evaluate: () => ({ outcome: 'ALLOW', reasonCode: 'policy.automatic_allow' }), + } as unknown as PermissionPolicy); + + expectPolicyUnavailable(() => request(service)); + expect(() => service.getPermissionRequest('ws-1', 'permission-request-1')).toThrowError( + expect.objectContaining({ code: 'NOT_FOUND' }), + ); + }); + + it('fails closed when policy evaluation returns an invalid decision shape', () => { + const service = createService({ + ref: { id: 'mindrail.permission', version: '0.1.0' }, + evaluate: () => ({ outcome: 'ALLOW', reasonCode: 'INVALID REASON' }), + } as unknown as PermissionPolicy); + + expectPolicyUnavailable(() => request(service)); + expect(() => service.getPermissionRequest('ws-1', 'permission-request-1')).toThrowError( + expect.objectContaining({ code: 'NOT_FOUND' }), + ); + }); +}); From 1ecea722f3620f1d95f83f8c878d33dc615d5ece Mon Sep 17 00:00:00 2001 From: tim8es <108188608+tim8es@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:51:02 +0500 Subject: [PATCH 10/11] ci: include invalid policy regressions in focused run --- .github/workflows/permission-engine-tdd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/permission-engine-tdd.yml b/.github/workflows/permission-engine-tdd.yml index 881f5d39..a84ab176 100644 --- a/.github/workflows/permission-engine-tdd.yml +++ b/.github/workflows/permission-engine-tdd.yml @@ -34,7 +34,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Focused permission regressions - run: pnpm exec vitest run test/runtime/permission-engine.test.ts + run: pnpm exec vitest run test/runtime/permission-engine.test.ts test/runtime/permission-policy-fail-closed.test.ts - name: Full quality gate run: pnpm check From ccfeb44983c6504629a1a7139e8dea0d75dee1f5 Mon Sep 17 00:00:00 2001 From: tim8es <108188608+tim8es@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:52:48 +0500 Subject: [PATCH 11/11] chore: remove temporary permission TDD workflow --- .github/workflows/permission-engine-tdd.yml | 43 --------------------- 1 file changed, 43 deletions(-) delete mode 100644 .github/workflows/permission-engine-tdd.yml diff --git a/.github/workflows/permission-engine-tdd.yml b/.github/workflows/permission-engine-tdd.yml deleted file mode 100644 index a84ab176..00000000 --- a/.github/workflows/permission-engine-tdd.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Permission Engine TDD - -on: - push: - branches: - - feature/permission-engine-v0-1 - -permissions: - contents: read - -concurrency: - group: permission-engine-tdd-${{ github.ref }} - cancel-in-progress: true - -jobs: - tdd: - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Install Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version-file: package.json - package-manager-cache: false - - - name: Install repository pnpm - run: npm install --global "$(node -p "require('./package.json').packageManager")" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Focused permission regressions - run: pnpm exec vitest run test/runtime/permission-engine.test.ts test/runtime/permission-policy-fail-closed.test.ts - - - name: Full quality gate - run: pnpm check - - - name: Coverage - run: pnpm test:coverage