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 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. 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/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({ diff --git a/src/runtime/permission-service.ts b/src/runtime/permission-service.ts new file mode 100644 index 00000000..5948391b --- /dev/null +++ b/src/runtime/permission-service.ts @@ -0,0 +1,263 @@ +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..0bb81576 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,23 @@ 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 +233,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', }; diff --git a/test/runtime/permission-engine.test.ts b/test/runtime/permission-engine.test.ts new file mode 100644 index 00000000..ac918d85 --- /dev/null +++ b/test/runtime/permission-engine.test.ts @@ -0,0 +1,504 @@ +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); + }); +}); 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' }), + ); + }); +});