blob: cfc8a6b1b133f238f04a1de91160edb1771e7bb0 [file]
/**
* PermissionEngine — runtime wrapper around core's pure `preToolUse()`.
*
* Owns:
* - requestId generation (uuid)
* - per-turn "remember" set scoped to a specific tool intent
* - parked Promise registry (one Promise per outstanding permission_request,
* keyed by requestId)
* - response routing back to the awaiting adapter
*
* Adapter contract (see AiSdkBackend tool execute wrapper):
*
* const decision = await engine.evaluate({ sessionId, turnId, toolUseId, toolName, args, mode });
* if (decision.kind === 'allow') { ...proceed with tool... }
* else if (decision.kind === 'block') { ...synthesize tool_result(isError) with decision.reason... }
* else if (decision.kind === 'prompt') {
* emit(decision.event); // PermissionRequestEvent
* const userResponse = await decision.parked; // resolves on respondToPermission()
* // record decision messages + ack event via callbacks
* }
*/
import { createHash } from 'node:crypto';
import { isAbsolute } from 'node:path';
import {
classifyToolUse,
matchToolPermissionRules,
preToolUse,
type PermissionMode,
type PermissionRequest,
type PermissionResponse,
type PreToolUseResult,
type ToolCategory,
type ToolExecutionFacts,
type ToolPermissionRule,
} from '@maka/core/permission';
import type { AnyPermissionRequestEvent, PermissionDecisionAckEvent } from '@maka/core/events';
import type {
HostedInteractionBridge,
HostedPermissionAnswer,
HostedPermissionSettlement,
} from '@maka/core/backend-types';
import {
DEFAULT_ADDITIONAL_PERMISSION_GRANT_TTL_MS,
AdditionalPermissionError,
assertAdditionalPermissionProposal,
freezeAdditionalPermissionGrant,
freezeAdditionalPermissionProposal,
type AdditionalPermissionGrant,
type AdditionalPermissionProposal,
} from './additional-permissions.js';
import {
DEFAULT_SANDBOX_ESCALATION_GRANT_TTL_MS,
SandboxEscalationError,
assertSandboxEscalationProposal,
freezeSandboxEscalationGrant,
freezeSandboxEscalationProposal,
type SandboxEscalationGrant,
type SandboxEscalationProposal,
} from './sandbox-escalation.js';
import { TurnScopedAwaitRegistry } from './turn-scoped-await-registry.js';
import {
RuntimeInteractionAdmissionRejectedError,
RuntimeInteractionClosedError,
RuntimeInteractionFailStopError,
RuntimeInteractionInvariantError,
type RuntimeInteractionClosureReason,
} from './interaction-authority.js';
// ============================================================================
// Per-turn state
// ============================================================================
interface TurnState {
turnId: string;
/** Tool-intent scopes granted with `rememberForTurn: true` in this turn. */
remembered: Set<string>;
rememberScopeIds: Map<string, string>;
/** Approved one-shot grants keyed by their bound tool invocation. */
additionalGrants: Map<string, PendingAdditionalPermissionGrant>;
/** Approved exact unsandboxed-command grants keyed by tool invocation. */
sandboxEscalationGrants: Map<string, PendingSandboxEscalationGrant>;
}
interface ParkedPermission {
sessionId: string;
turnId: string;
toolUseId: string;
toolName: string;
category: ToolCategory;
scopeKey: string;
rememberForTurnAllowed: boolean;
hosted?: {
bridge: HostedInteractionBridge;
commitStarted: boolean;
};
additionalProposal?: AdditionalPermissionProposal;
sandboxEscalationProposal?: SandboxEscalationProposal;
}
interface PendingAdditionalPermissionGrant {
grant: AdditionalPermissionGrant;
consumed: boolean;
}
interface PendingSandboxEscalationGrant {
grant: SandboxEscalationGrant;
consumed: boolean;
}
// ============================================================================
// Evaluate result shapes
// ============================================================================
export type EvaluateResult =
| { kind: 'allow'; category: ToolCategory }
| {
kind: 'block';
category: ToolCategory;
reason: string;
/** Present for an invocation-local explicit deny so observers record a failed invocation. */
decisionEvent?: PermissionDecisionAckEvent;
}
| {
kind: 'prompt';
category: ToolCategory;
event: AnyPermissionRequestEvent;
/** Stable non-secret identity used for remember-for-turn siblings. */
rememberScopeId?: string;
/** Exact local settlement owned by PermissionEngine for hosted admission. */
settlement?: HostedPermissionSettlement;
/** Resolves when the user responds via respondToPermission(). */
parked: Promise<PermissionResponse>;
};
export interface EvaluateInput {
/** The session this evaluation runs in. */
sessionId: string;
/** Current agent turn id (groups permission state). */
turnId: string;
/** Exact active Run identity when a backend can provide it. */
runId?: string;
/** Exact hosted Interaction bridge. Omitted for embedded execution. */
hostedInteraction?: HostedInteractionBridge;
/** The SDK's id for the tool invocation. */
toolUseId: string;
toolName: string;
args: unknown;
categoryHint?: ToolCategory;
/** Session's current permission mode. */
mode: PermissionMode;
/** Optional hint shown to user in the dialog. */
hint?: string;
/** Runtime-normalized one-shot permission proposal for this exact tool intent. */
additionalPermissionProposal?: AdditionalPermissionProposal;
/** Runtime-normalized request to execute this exact Bash command without a platform sandbox. */
sandboxEscalationProposal?: SandboxEscalationProposal;
/** Canonical cwd displayed with an additional permission request. */
cwd?: string;
/** Optional trusted facts about the executor that would run this tool. */
executionFacts?: ToolExecutionFacts;
/** Whether the tool participates in the base mode policy when no explicit rule matches. */
permissionRequired?: boolean;
/** Invocation-local rules. Explicit deny wins over allow, then base mode applies. */
permissionRules?: readonly ToolPermissionRule[];
/** Optional trusted platform sandbox availability for sandbox-aware policy. */
sandbox?: {
platformSandboxAvailable: boolean;
};
}
// ============================================================================
// Engine
// ============================================================================
export interface PermissionEngineDeps {
/** Generate a fresh uuid. Injectable for tests. */
newId: () => string;
/** Wall-clock for event timestamps. Injectable for tests. */
now: () => number;
}
export class PermissionEngine {
private readonly turns = new Map<string, TurnState>();
private readonly parked = new TurnScopedAwaitRegistry<PermissionResponse, ParkedPermission>();
private readonly deferredTurnClosures = new Set<string>();
constructor(private readonly deps: PermissionEngineDeps) {}
/** Begin tracking a new turn. Idempotent. */
beginTurn(turnId: string): void {
if (!this.turns.has(turnId)) {
this.turns.set(turnId, {
turnId,
remembered: new Set(),
rememberScopeIds: new Map(),
additionalGrants: new Map(),
sandboxEscalationGrants: new Map(),
});
}
this.parked.beginTurn(turnId);
}
/** End tracking, rejecting any still-parked requests as user_stop. */
endTurn(turnId: string, reason: 'completed' | 'aborted' = 'completed'): void {
const state = this.turns.get(turnId);
if (!state) return;
if (this.parked.entries(turnId).some(([, parked]) => parked.hosted)) {
this.deferredTurnClosures.add(turnId);
this.finishDeferredTurnClosure(turnId);
return;
}
this.parked.endTurn(turnId, (requestId, parked) => {
const message = `Turn ${turnId} ${reason} before permission request ${requestId} was answered`;
return parked.additionalProposal
? new AdditionalPermissionError({
stage: 'approval',
reason: 'additional_permission_aborted',
message,
recoverable: true,
})
: parked.sandboxEscalationProposal
? new SandboxEscalationError({
stage: 'approval',
reason: 'sandbox_escalation_aborted',
message,
recoverable: true,
})
: new Error(message);
});
this.turns.delete(turnId);
}
/**
* Evaluate a tool intent against the policy matrix and session state.
* Returns one of three kinds; for 'prompt' the caller emits the event
* and awaits `parked`.
*/
evaluate(input: EvaluateInput): EvaluateResult {
const state = this.requireTurn(input.turnId);
const hostedInteraction = input.hostedInteraction;
if (
hostedInteraction &&
(hostedInteraction.sessionId !== input.sessionId ||
hostedInteraction.turnId !== input.turnId ||
(input.runId !== undefined && hostedInteraction.runId !== input.runId))
) {
throw new RuntimeInteractionInvariantError(
`Permission evaluation has a mismatched hosted Run for turn ${input.turnId}`,
);
}
const args = snapshotPermissionArgs(input.args);
const category = classifyToolUse({
toolName: input.toolName,
args,
...(input.categoryHint !== undefined ? { categoryHint: input.categoryHint } : {}),
});
const ruleDecision = matchToolPermissionRules({
toolName: input.toolName,
args,
category,
rules: input.permissionRules ?? [],
});
const hasAdditionalProposal = input.additionalPermissionProposal !== undefined;
const hasSandboxEscalationProposal = input.sandboxEscalationProposal !== undefined;
const hasOneShotProposal = hasAdditionalProposal || hasSandboxEscalationProposal;
if (ruleDecision === 'allow' && !hasOneShotProposal) return { kind: 'allow', category };
if (ruleDecision === 'deny') {
const requestId = this.deps.newId();
return {
kind: 'block',
category,
reason: `Tool ${input.toolName} was denied by an invocation permission rule`,
decisionEvent: {
type: 'permission_decision_ack',
id: this.deps.newId(),
turnId: input.turnId,
ts: this.deps.now(),
requestId,
toolUseId: input.toolUseId,
decision: 'deny',
},
};
}
if (ruleDecision === undefined && input.permissionRequired === false && !hasOneShotProposal) {
return { kind: 'allow', category };
}
const pre: PreToolUseResult = preToolUse({
toolName: input.toolName,
args,
...(input.categoryHint !== undefined ? { categoryHint: input.categoryHint } : {}),
...(input.executionFacts !== undefined ? { executionFacts: input.executionFacts } : {}),
...(input.sandbox !== undefined ? { sandbox: input.sandbox } : {}),
mode: input.mode,
// Hosted siblings always establish their own request and durable outcome.
// The Host may settle a late sibling during admission from its remembered winner.
turnRemembered: hostedInteraction ? new Set() : state.remembered,
});
let additional = input.additionalPermissionProposal;
let sandboxEscalation = input.sandboxEscalationProposal;
if (additional && sandboxEscalation) {
return {
kind: 'block',
category: pre.category,
reason: 'Additional permissions and sandbox escalation cannot be requested together.',
};
}
if (additional) {
try {
assertAdditionalPermissionProposal({
proposal: additional,
toolName: input.toolName,
args,
});
additional = freezeAdditionalPermissionProposal(additional);
} catch (error) {
return {
kind: 'block',
category: pre.category,
reason:
error instanceof AdditionalPermissionError
? error.message
: 'Additional permission proposal validation failed.',
};
}
if (input.mode === 'explore') {
return {
kind: 'block',
category: pre.category,
reason: 'Additional permissions are blocked in explore mode.',
};
}
if (input.mode === 'bypass') return { kind: 'allow', category: pre.category };
if (typeof input.cwd !== 'string' || !isAbsolute(input.cwd)) {
return {
kind: 'block',
category: pre.category,
reason: 'Additional permission requests require a canonical cwd.',
};
}
}
if (sandboxEscalation) {
try {
assertSandboxEscalationProposal({
proposal: sandboxEscalation,
toolName: input.toolName,
args,
cwd: input.cwd ?? '',
});
sandboxEscalation = freezeSandboxEscalationProposal(sandboxEscalation);
} catch (error) {
return {
kind: 'block',
category: pre.category,
reason:
error instanceof SandboxEscalationError
? error.message
: 'Sandbox escalation proposal validation failed.',
};
}
if (input.mode === 'explore') {
return {
kind: 'block',
category: pre.category,
reason: 'Sandbox escalation is blocked in explore mode.',
};
}
if (input.mode === 'bypass') return { kind: 'allow', category: pre.category };
if (typeof input.cwd !== 'string' || !isAbsolute(input.cwd)) {
return {
kind: 'block',
category: pre.category,
reason: 'Sandbox escalation requests require a canonical cwd.',
};
}
}
const baseExplicitlyAllowed =
ruleDecision === 'allow' ||
(ruleDecision === undefined && input.permissionRequired === false);
const baseAllowed = baseExplicitlyAllowed || pre.proceed;
if (!baseExplicitlyAllowed && pre.blockReason !== undefined) {
return { kind: 'block', category: pre.category, reason: pre.blockReason };
}
if (baseAllowed && !additional && !sandboxEscalation) {
return { kind: 'allow', category: pre.category };
}
if (!additional && !sandboxEscalation && !pre.partialRequest) {
// Defensive: pre.proceed=false && !blockReason && !partialRequest is
// unreachable per the type contract, but TS doesn't know that. Treat
// as block to fail safe.
return {
kind: 'block',
category: pre.category,
reason: 'PermissionEngine: invariant violated — no partialRequest in prompt branch',
};
}
const requestId = this.deps.newId();
const event: AnyPermissionRequestEvent = additional
? {
type: 'permission_request',
kind: 'additional_permissions',
id: this.deps.newId(),
turnId: input.turnId,
ts: this.deps.now(),
requestId,
toolUseId: input.toolUseId,
toolName: input.toolName,
category: pre.category,
reason: 'additional_permissions',
args: undefined,
additionalPermissions: additional.profile,
cwd: input.cwd!,
justification: additional.justification,
intentHash: additional.intentHash,
permissionsHash: additional.permissionsHash,
risk: additional.risk,
alsoApprovesToolExecution: !baseAllowed,
availableDecisions: ['allow_once', 'deny'],
rememberForTurnAllowed: false,
...(input.hint !== undefined ? { hint: input.hint } : {}),
}
: sandboxEscalation
? {
type: 'permission_request',
kind: 'sandbox_escalation',
id: this.deps.newId(),
turnId: input.turnId,
ts: this.deps.now(),
requestId,
toolUseId: input.toolUseId,
toolName: 'Bash',
category: pre.category,
reason: 'sandbox_escalation',
args: undefined,
command: sandboxEscalation.command,
cwd: sandboxEscalation.cwd,
justification: sandboxEscalation.justification,
intentHash: sandboxEscalation.intentHash,
commandHash: sandboxEscalation.commandHash,
trigger: sandboxEscalation.trigger,
risk: sandboxEscalation.risk,
alsoApprovesToolExecution: !baseAllowed,
availableDecisions: ['allow_once', 'deny'],
rememberForTurnAllowed: false,
...(input.hint !== undefined ? { hint: input.hint } : {}),
}
: {
type: 'permission_request',
kind: 'tool_permission',
id: this.deps.newId(),
turnId: input.turnId,
ts: this.deps.now(),
requestId,
toolUseId: input.toolUseId,
toolName: pre.partialRequest!.toolName,
category: pre.partialRequest!.category,
reason: pre.partialRequest!.reason,
args: pre.partialRequest!.args,
rememberForTurnAllowed: pre.partialRequest!.rememberForTurnAllowed,
...(input.hint !== undefined ? { hint: input.hint } : {}),
};
const parked = this.parked.park(input.turnId, requestId, {
sessionId: input.sessionId,
turnId: input.turnId,
toolUseId: input.toolUseId,
toolName: input.toolName,
category: pre.category,
scopeKey: pre.scopeKey,
rememberForTurnAllowed: additional
? false
: sandboxEscalation
? false
: pre.partialRequest!.rememberForTurnAllowed,
...(hostedInteraction ? { hosted: { bridge: hostedInteraction, commitStarted: false } } : {}),
...(additional ? { additionalProposal: additional } : {}),
...(sandboxEscalation ? { sandboxEscalationProposal: sandboxEscalation } : {}),
});
return {
kind: 'prompt',
category: pre.category,
event,
...(event.kind === 'tool_permission' && event.rememberForTurnAllowed
? {
rememberScopeId: this.rememberScopeId(state, pre.scopeKey, requestId),
}
: {}),
...(hostedInteraction
? { settlement: this.createHostedSettlement(input.turnId, requestId) }
: {}),
parked,
};
}
private createHostedSettlement(turnId: string, requestId: string): HostedPermissionSettlement {
return Object.freeze({
applyAnswer: async (answer: HostedPermissionAnswer): Promise<void> => {
if (Object.hasOwn(answer, 'requestId')) {
throw new RuntimeInteractionInvariantError(
`Permission settlement ${requestId} received a routed answer`,
);
}
this.settleCommittedResponse(turnId, requestId, answer);
},
applyClosure: async (reason: RuntimeInteractionClosureReason): Promise<void> => {
if (!this.closeRequest(turnId, requestId, reason)) {
throw new RuntimeInteractionInvariantError(
`Permission closure did not take ${requestId} from turn ${turnId}`,
);
}
},
});
}
private settleCommittedResponse(
turnId: string,
requestId: string,
answer: HostedPermissionAnswer,
): void {
const response: PermissionResponse = {
requestId,
decision: answer.decision,
...(answer.rememberForTurn !== undefined ? { rememberForTurn: answer.rememberForTurn } : {}),
...(answer.reviewer !== undefined ? { reviewer: answer.reviewer } : {}),
...(answer.rationale !== undefined ? { rationale: answer.rationale } : {}),
...(answer.riskLevel !== undefined ? { riskLevel: answer.riskLevel } : {}),
};
this.assertValidResponse(response);
const state = this.turns.get(turnId);
const parked = this.parked.entries(turnId).find(([id]) => id === requestId)?.[1];
if (!state || !parked?.hosted) {
throw new RuntimeInteractionInvariantError(
`Permission settlement did not exact-take ${requestId} from turn ${turnId}`,
);
}
this.applyResponse(turnId, response, state, parked);
}
private assertValidResponse(response: PermissionResponse): void {
if (
!response ||
typeof response.requestId !== 'string' ||
(response.decision !== 'allow' && response.decision !== 'deny') ||
(response.rememberForTurn !== undefined && typeof response.rememberForTurn !== 'boolean') ||
(response.reviewer !== undefined &&
response.reviewer !== 'user' &&
response.reviewer !== 'auto_review') ||
(response.rationale !== undefined && typeof response.rationale !== 'string') ||
(response.rationale !== undefined && response.reviewer !== 'auto_review') ||
(response.riskLevel !== undefined &&
!['low', 'medium', 'high', 'critical'].includes(response.riskLevel))
) {
throw new Error('Invalid permission response');
}
}
/**
* Route a user's response to the parked Promise. Idempotent on stray
* responses for unknown requestIds (logs and ignores).
*
* Returns the resolved ParkedRequest (for the caller to write
* PermissionDecisionMessage + emit PermissionDecisionAckEvent), or null
* if the requestId was unknown.
*/
recordResponse(
turnId: string,
response: PermissionResponse,
): { category: ToolCategory; toolUseId: string } | null {
this.assertValidResponse(response);
const state = this.turns.get(turnId);
if (!state) return null;
const parked = this.parked
.entries(turnId)
.find(([requestId]) => requestId === response.requestId)?.[1];
if (!parked) return null;
if (parked.hosted) {
if (!parked.hosted.commitStarted) {
parked.hosted.commitStarted = true;
const answer = runtimePermissionAnswer(response);
void parked.hosted.bridge
.commitPermissionAnswer({
requestId: response.requestId,
answer,
})
.catch((error: unknown) => {
if (
error instanceof RuntimeInteractionAdmissionRejectedError &&
error.reason === 'run_closed' &&
error.requestId === response.requestId
) {
return;
}
this.rejectRequest(
turnId,
response.requestId,
error instanceof RuntimeInteractionFailStopError ||
error instanceof RuntimeInteractionInvariantError
? error
: new RuntimeInteractionFailStopError(
`Could not confirm the canonical outcome for permission ${response.requestId}`,
error,
),
);
});
}
return { category: parked.category, toolUseId: parked.toolUseId };
}
return this.applyResponse(turnId, response, state, parked);
}
private applyResponse(
turnId: string,
response: PermissionResponse,
state: TurnState,
parked: ParkedPermission,
): { category: ToolCategory; toolUseId: string } {
if (
(parked.additionalProposal || parked.sandboxEscalationProposal) &&
response.rememberForTurn === true
) {
throw new Error('One-shot permission responses cannot use rememberForTurn');
}
if (
response.decision === 'allow' &&
response.rememberForTurn &&
!parked.rememberForTurnAllowed
) {
throw new Error('This permission request cannot be remembered for the turn');
}
if (
response.decision === 'allow' &&
response.rememberForTurn &&
parked.rememberForTurnAllowed
) {
state.remembered.add(parked.scopeKey);
// The user allowed this scope for the whole turn, so other requests
// already parked under the same scope (e.g. the rest of a parallel
// browser_* batch) must not each re-prompt. Resolve them now — each
// tool's own coroutine then emits its own permission_decision_ack, so the
// UI queue drains without a second click. The current request was already
// selected explicitly, so the snapshot must not auto-resolve it.
if (!parked.hosted) {
for (const [otherId, other] of this.parked.entries(turnId)) {
if (
otherId !== response.requestId &&
other.rememberForTurnAllowed &&
other.scopeKey === parked.scopeKey
) {
this.parked.resolve(turnId, otherId, {
requestId: otherId,
decision: 'allow',
rememberForTurn: true,
});
}
}
}
}
if (response.decision === 'allow' && parked.additionalProposal) {
if (state.additionalGrants.has(parked.toolUseId)) {
throw new Error(`Additional permission grant already exists for tool ${parked.toolUseId}`);
}
const issuedAt = this.deps.now();
state.additionalGrants.set(parked.toolUseId, {
consumed: false,
grant: freezeAdditionalPermissionGrant({
grantId: this.deps.newId(),
sessionId: parked.sessionId,
turnId: parked.turnId,
toolUseId: parked.toolUseId,
toolName: parked.toolName,
intentHash: parked.additionalProposal.intentHash,
permissionsHash: parked.additionalProposal.permissionsHash,
profile: parked.additionalProposal.profile,
normalizedPaths: parked.additionalProposal.normalizedPaths,
risk: parked.additionalProposal.risk,
issuedAt,
expiresAt: issuedAt + DEFAULT_ADDITIONAL_PERMISSION_GRANT_TTL_MS,
}),
});
}
if (response.decision === 'allow' && parked.sandboxEscalationProposal) {
if (state.sandboxEscalationGrants.has(parked.toolUseId)) {
throw new Error(`Sandbox escalation grant already exists for tool ${parked.toolUseId}`);
}
const issuedAt = this.deps.now();
const proposal = parked.sandboxEscalationProposal;
state.sandboxEscalationGrants.set(parked.toolUseId, {
consumed: false,
grant: freezeSandboxEscalationGrant({
grantId: this.deps.newId(),
sessionId: parked.sessionId,
turnId: parked.turnId,
toolUseId: parked.toolUseId,
toolName: 'Bash',
intentHash: proposal.intentHash,
commandHash: proposal.commandHash,
command: proposal.command,
cwd: proposal.cwd,
risk: proposal.risk,
issuedAt,
expiresAt: issuedAt + DEFAULT_SANDBOX_ESCALATION_GRANT_TTL_MS,
}),
});
}
const resolvedResponse: PermissionResponse =
parked.additionalProposal || parked.sandboxEscalationProposal
? {
requestId: response.requestId,
decision: response.decision,
...(response.reviewer !== undefined ? { reviewer: response.reviewer } : {}),
...(response.rationale !== undefined ? { rationale: response.rationale } : {}),
...(response.riskLevel !== undefined ? { riskLevel: response.riskLevel } : {}),
}
: parked.rememberForTurnAllowed
? response
: { ...response, rememberForTurn: false };
this.parked.resolve(turnId, response.requestId, resolvedResponse);
this.finishDeferredTurnClosure(turnId);
return { category: parked.category, toolUseId: parked.toolUseId };
}
/**
* Fail one parked request without ending the whole turn.
* Used by runtime-level permission timeouts so late UI responses do not
* resolve a tool call that has already failed closed.
*/
expireRequest(
turnId: string,
requestId: string,
reason: string,
): { category: ToolCategory; toolUseId: string } | null {
const metadata = this.parked.entries(turnId).find(([id]) => id === requestId)?.[1];
if (!metadata) return null;
const error = metadata.additionalProposal
? new AdditionalPermissionError({
stage: 'approval',
reason: 'additional_permission_timeout',
message: reason,
recoverable: true,
})
: metadata.sandboxEscalationProposal
? new SandboxEscalationError({
stage: 'approval',
reason: 'sandbox_escalation_timeout',
message: reason,
recoverable: true,
})
: new Error(reason);
const parked = this.parked.reject(turnId, requestId, error);
this.finishDeferredTurnClosure(turnId);
if (!parked) return null;
return { category: parked.category, toolUseId: parked.toolUseId };
}
closeRequest(
turnId: string,
requestId: string,
reason: RuntimeInteractionClosureReason,
message = `Permission request ${requestId} closed: ${reason}`,
): { category: ToolCategory; toolUseId: string } | null {
if (reason === 'timed_out') return this.expireRequest(turnId, requestId, message);
const parked = this.parked.reject(
turnId,
requestId,
new RuntimeInteractionClosedError(requestId, reason),
);
this.finishDeferredTurnClosure(turnId);
return parked ? { category: parked.category, toolUseId: parked.toolUseId } : null;
}
rejectRequest(
turnId: string,
requestId: string,
error: Error,
): { category: ToolCategory; toolUseId: string } | null {
const parked = this.parked.reject(turnId, requestId, error);
this.finishDeferredTurnClosure(turnId);
return parked ? { category: parked.category, toolUseId: parked.toolUseId } : null;
}
/** Test/debug accessor. */
pendingCount(turnId: string): number {
return this.parked.pendingCount(turnId);
}
consumeAdditionalPermissionGrant(input: {
sessionId: string;
turnId: string;
toolUseId: string;
toolName: string;
intentHash: string;
}): AdditionalPermissionGrant | undefined {
const state = this.turns.get(input.turnId);
const pending = state?.additionalGrants.get(input.toolUseId);
if (!pending) return undefined;
if (pending.consumed) {
throw new AdditionalPermissionError({
stage: 'consume',
reason: 'grant_already_consumed',
});
}
const grant = pending.grant;
if (grant.expiresAt <= this.deps.now()) {
state!.additionalGrants.delete(input.toolUseId);
throw new AdditionalPermissionError({
stage: 'consume',
reason: 'grant_expired',
});
}
if (
grant.sessionId !== input.sessionId ||
grant.turnId !== input.turnId ||
grant.toolUseId !== input.toolUseId ||
grant.toolName !== input.toolName ||
grant.intentHash !== input.intentHash
) {
throw new AdditionalPermissionError({
stage: 'consume',
reason: 'grant_intent_mismatch',
});
}
pending.consumed = true;
return grant;
}
consumeSandboxEscalationGrant(input: {
sessionId: string;
turnId: string;
toolUseId: string;
toolName: string;
intentHash: string;
command: string;
cwd: string;
}): SandboxEscalationGrant | undefined {
const state = this.turns.get(input.turnId);
const pending = state?.sandboxEscalationGrants.get(input.toolUseId);
if (!pending) return undefined;
if (pending.consumed) {
throw new SandboxEscalationError({
stage: 'consume',
reason: 'sandbox_escalation_grant_consumed',
});
}
const grant = pending.grant;
if (grant.expiresAt <= this.deps.now()) {
state!.sandboxEscalationGrants.delete(input.toolUseId);
throw new SandboxEscalationError({
stage: 'consume',
reason: 'sandbox_escalation_grant_expired',
});
}
if (
grant.sessionId !== input.sessionId ||
grant.turnId !== input.turnId ||
grant.toolUseId !== input.toolUseId ||
grant.toolName !== input.toolName ||
grant.intentHash !== input.intentHash
) {
throw new SandboxEscalationError({
stage: 'consume',
reason: 'sandbox_escalation_intent_mismatch',
});
}
if (grant.command !== input.command) {
throw new SandboxEscalationError({
stage: 'consume',
reason: 'sandbox_escalation_command_mismatch',
});
}
if (grant.cwd !== input.cwd) {
throw new SandboxEscalationError({
stage: 'consume',
reason: 'sandbox_escalation_cwd_mismatch',
});
}
pending.consumed = true;
return grant;
}
private requireTurn(turnId: string): TurnState {
let state = this.turns.get(turnId);
if (!state) {
// Auto-begin: callers may forget. This is a soft guarantee.
state = {
turnId,
remembered: new Set(),
rememberScopeIds: new Map(),
additionalGrants: new Map(),
sandboxEscalationGrants: new Map(),
};
this.turns.set(turnId, state);
this.parked.beginTurn(turnId);
}
return state;
}
private finishDeferredTurnClosure(turnId: string): void {
if (!this.deferredTurnClosures.has(turnId) || this.parked.pendingCount(turnId) !== 0) return;
this.deferredTurnClosures.delete(turnId);
this.parked.endTurn(
turnId,
(requestId) =>
new RuntimeInteractionInvariantError(
`Hosted permission ${requestId} escaped exact Run closure`,
),
);
this.turns.delete(turnId);
}
private rememberScopeId(state: TurnState, scopeKey: string, requestId: string): string {
const existing = state.rememberScopeIds.get(scopeKey);
if (existing) return existing;
const id = createHash('sha256').update(requestId, 'utf8').digest('hex');
state.rememberScopeIds.set(scopeKey, id);
return id;
}
}
function runtimePermissionAnswer(response: PermissionResponse): HostedPermissionAnswer {
return {
decision: response.decision,
...(response.rememberForTurn !== undefined
? { rememberForTurn: response.rememberForTurn }
: {}),
...(response.reviewer !== undefined ? { reviewer: response.reviewer } : {}),
...(response.rationale !== undefined ? { rationale: response.rationale } : {}),
...(response.riskLevel !== undefined ? { riskLevel: response.riskLevel } : {}),
};
}
function snapshotPermissionArgs(value: unknown): unknown {
return snapshotPermissionValue(value, new WeakSet<object>());
}
function snapshotPermissionValue(value: unknown, seen: WeakSet<object>): unknown {
if (value === null || typeof value !== 'object') return value;
if (seen.has(value)) throw new Error('Permission arguments must not contain cycles');
seen.add(value);
if (Array.isArray(value)) {
return Object.freeze(value.map((entry) => snapshotPermissionValue(entry, seen)));
}
const output: Record<string, unknown> = {};
for (const key of Object.keys(value)) {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor || !('value' in descriptor)) {
throw new Error(`Permission argument ${key} must be a plain data property`);
}
output[key] = snapshotPermissionValue(descriptor.value, seen);
}
return Object.freeze(output);
}
// ============================================================================
// Default deps factory (Node / Bun)
// ============================================================================
export function createDefaultPermissionEngineDeps(): PermissionEngineDeps {
return {
newId: () => crypto.randomUUID(),
now: () => Date.now(),
};
}