diff --git a/CHANGELOG.md b/CHANGELOG.md index a9b05d719..bfe96c6ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - 2026-09-28 ### Added +- Declarative permission policies (LOU-X2): `permissions?: PermissionRule[]` on `createAgent()`, `ExecuteOptions` and `resumeAfterApproval()` options. A rule is `{ tool: string | string[] | RegExp | '*', when?: (args, { toolName, toolCallId, sessionId }) => boolean | Promise, action: 'allow' | 'deny' | 'ask', reason? }`; rules are checked in order for each tool call (after argument validation and `preToolCall` hooks, before `needsApproval`) and the first match decides: `allow` runs the call without approval even when `needsApproval` would ask, `deny` gives the model a tool error with the new `kind: 'denied'` (`ToolDeniedError`, plus `reason`) and streams `tool.error`, `ask` pauses for approval (or asks `approve`). No match keeps today's behaviour. New helpers `allow(tools)`, `deny(tools, reason?)`, `ask(tools)` and types `PermissionRule`, `PermissionAction`, `PermissionToolMatcher`, `PermissionContext`, `PermissionDecision`, `PermissionDecisionEntry`, `PermissionOptions`. Audit log: `onPermissionDecision(entry)` gets `{ toolName, toolCallId, decision: 'allow' | 'deny' | 'ask' | 'default', rule?: { index, reason? }, args?, at }` per call (`args` left out under `redactContent`), and streams get the same entry as the new typed `permission.decision` event (`PermissionDecisionEvent`; additive, the event schema version stays 1). Neither is produced unless `permissions` or `onPermissionDecision` is set. Sub-agents inherit the lead's rules (checked before their own) and its `onPermissionDecision`. See docs/approvals.md#permission-policies. - Compaction events and the `compaction` option (LOU-W3.2): `agent.stream()`, `session.stream()` and `AgentExecutor.stream()` emit two new typed events whenever the compaction hook compacts a request: `compaction.start` (`strategy`, `tokensBefore`, `contextWindow`, `thresholdTokens`) before the work and `compaction.done` (`strategy`, `tokensBefore`, `tokensAfter`, `prunedToolCallIds`, `summary?`, `error?: { message }`) after it, inside the step and before the model call; `error` is set when the strategy failed and the hook fell back. Additive: the event schema version stays 1. `GenerateHookContext` gains the optional `emit(event)` (set in streamed runs only) that the hook uses. `createAgent({ compaction })` takes `true` (the prune hook with its defaults) or `{ strategy?, thresholdPercent?, contextWindow?, protectedTokens?, summarizer? }`, where `summarizer` (a `'provider/model'` string or an `LLMProvider`) selects `twoPhaseStrategy()` with that model; `summarizer` together with `strategy` is a `ConfigurationError`. `createAgent({ hooks })` (new) takes any `AgentHook[]`, run before the compaction hook. See docs/compaction.md. - Connect MCP servers from config (LOU-Z4): `connectMcp(servers, { lazy?, onError?, logger? })` (`@loushy/build-ai-agent/mcp` and the root) connects stdio (`command`/`args`/`env`) and streamable HTTP (`url`/`headers`) servers, lists their tools as `__` descriptors and returns `{ tools, close(), status() }`. Every server connects up front (listing tools needs a connection); `lazy` (default `true`) reconnects on the next tool call after `close()` or a dropped connection. `onError: 'skip'` leaves a failing server out with a warning instead of rejecting. `createAgent({ mcpServers })` connects them on the new `agent.ready()` or the first `send()` / `stream()` and adds their tools; the new `agent.close()` disconnects them (both are no-ops without `mcpServers`). `specToAgent()` now connects `spec.mcpServers` the same way (closes TODO(LOU-D20.2)); `agent.mcpServers` still lists them. `loadMcpTools()` accepts any `{ listTools, callTool }` (`McpClientLike`). See docs/configuration.md#connect-mcp-servers-mcpservers-connectmcp. - Error codes with fixes (LOU-D2): every `SDKError` now has a stable `code` (`LOUSHY__`, e.g. `LOUSHY_CONFIG_MISSING_PROVIDER`, `LOUSHY_PEER_MISSING`, `LOUSHY_SPEC_INVALID`, `LOUSHY_SESSION_AWAITING_APPROVAL`), a one-sentence `hint` and a `docs` link to its section of the new docs/errors.md; its message ends with a `[code] hint (docs)` line (`detail` is the message without it), and `toString()` always shows it. `ERROR_CODES` / `ErrorCode` (new exports) are the registry, and a test keeps it, the codes used in `src/` and docs/errors.md in sync. The plain `Error`s thrown by `createAgent()` (no model, `instructions` + `prompt`, `sessionId` without checkpoints), `resolveProvider()` (bad spec, unknown prefix, missing API key, missing peer), `AgentExecutor.execute()` / `stream()` (missing `provider` / `agent` / `input`, approval with no `approvalStore`), `resumeAfterApproval()` (unknown approval), sessions (invalid id, corrupt file, second iteration, no stream runner), `loadSpec()` and `specToAgent()` are now `ConfigurationError` / `ValidationError` / `SDKError` with codes; `MissingPeerDependencyError` extends `SDKError`. Existing message text is unchanged (the help line is appended). `loadSpec()` suggests the field a top-level typo meant (`unknown field 'promt' (did you mean 'prompt'?)`). See docs/errors.md. diff --git a/README.md b/README.md index 891ac1428..fca1aae52 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ agent can be an `agent.yaml` spec served with `npx loushy dev agent.yaml` ## Features - **Tools**: `defineTool()` with a zod `input`; arguments are typed, validated, and run in parallel. [Tools](docs/tools.md) -- **Approvals**: `needsApproval` pauses a run; `agent.approvals.resolve()` continues it, or `approve` decides in code. [Approvals](docs/approvals.md) +- **Approvals and permission policies**: `needsApproval` pauses a run; `agent.approvals.resolve()` continues it, or `approve` decides in code. `permissions` rules allow, deny or ask per tool before that, with an audit log. [Approvals](docs/approvals.md) - **Sessions**: `agent.session()` keeps a multi-turn conversation in memory, files or SQLite. [Sessions](docs/sessions.md) - **Structured output**: `output: zodSchema` makes the final reply a typed, validated `result.object`, with one repair step. [Structured output](docs/structured-output.md) - **Streaming**: `agent.stream()` and `session.stream()` yield typed, versioned JSON events ready for SSE. [Streaming](docs/streaming.md) diff --git a/docs/approvals.md b/docs/approvals.md index bee20b2dd..fdf0f2429 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -51,6 +51,73 @@ approval stops the batch: the calls before it run, the run pauses on it, and the calls after it run once it is decided (see [Approvals in the middle of a tool batch](./durable-execution.md#approvals-in-the-middle-of-a-tool-batch)). +## Permission policies + +`permissions` sets rules for the whole agent instead of tool by tool: a list of +`{ tool, when?, action, reason? }` rules, checked in order for every tool call +before the tool's own `needsApproval`. The first rule that matches decides: + +- `allow` runs the call, without approval even if `needsApproval` would ask. +- `deny` does not run it. The model gets a tool error with `kind: 'denied'` + and the rule's `reason` (see [Tool errors](./tools.md)), and streams see + `tool.error`. +- `ask` pauses the run for approval, exactly like `needsApproval` (or asks the + `approve` callback). + +When no rule matches, the tool's own `needsApproval` decides, as before. +`tool` is a name, a list of names, a `RegExp` tested against the name, or +`'*'` for every tool. `when` narrows a rule to some calls: it gets the +validated arguments (after `preToolCall` hooks) and `{ toolName, toolCallId, +sessionId }`, and may be async; when it throws, the call fails with that +error. `allow(tools)`, `deny(tools, reason?)` and `ask(tools)` build the +common rules. + +```ts +import { allow, ask, createAgent, defineTool, deny, type PermissionRule } from '@loushy/build-ai-agent'; +import { z } from 'zod'; + +const shell = defineTool({ + name: 'shell', + description: 'Run a shell command', + input: z.object({ command: z.string() }), + execute: async ({ command }) => `ran ${command}`, +}); + +const noDeletes: PermissionRule = { + tool: 'shell', + when: (args) => /\brm\b/.test(String(args.command)), + action: 'deny', + reason: 'Deleting files is not allowed', +}; + +const agent = createAgent({ + provider, + instructions: 'You are a coding assistant.', + tools: [shell], + permissions: [ + noDeletes, // first match wins: this beats the `ask` below + allow(/^read_/), // read-only tools never pause + deny('delete_file', 'Use the trash tool instead'), + ask(['shell', 'write_file']), + ], + onPermissionDecision: (entry) => console.log(entry.at, entry.toolName, entry.decision, entry.rule?.reason), +}); +``` + +`onPermissionDecision` is the audit log: it is called once per tool call +(except calls already refused for invalid arguments) with +`{ toolName, toolCallId, decision, rule?, args?, at }`. `decision` is the +matching rule's action or `'default'` when none matched, `rule` is that rule's +`{ index, reason? }`, `at` is an ISO timestamp, and `args` is left out when the +run sets `redactContent`. Streams get the same entry as a `permission.decision` +event (see [Streaming](./streaming.md)). Both are only produced when the agent +sets `permissions` or `onPermissionDecision`. + +Both options also exist on `AgentExecutor.execute()` / `stream()` and +`resumeAfterApproval()`. Sub-agents inherit the lead agent's rules, checked +before the sub-agent's own, and report their decisions to the lead's +`onPermissionDecision`. + ## `createAgent()` agents - A paused `send()` resolves (it does not throw) with diff --git a/docs/streaming.md b/docs/streaming.md index 265a8d4cc..834b58f91 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -107,6 +107,7 @@ The event types and their extra fields: | `tool.done` | `toolCallId`, `toolName`, `result: unknown`, `durationMs: number` | A tool call returned. `result` is the value as it would be JSON-encoded (`undefined` becomes `null`, a `Date` becomes a string). `durationMs` counts from its `tool.start`. | | `tool.error` | `toolCallId`, `toolName`, `error: { name: string, message: string }`, `durationMs: number` | A tool call failed: it threw, its arguments did not match its schema (`name: 'ToolArgumentsValidationError'`), or the tool does not exist. The model gets the error as the call's result and the run continues. | | `approval.requested` | `approvalId: string`, `toolCallId`, `toolName`, `args: Record` | A tool call needs a human decision. The run then stops; resume it with `agent.approvals.resolve()` or `resumeAfterApproval()` (see [Approvals](./approvals.md)). | +| `permission.decision` | `toolCallId`, `toolName`, `decision: 'allow' \| 'deny' \| 'ask' \| 'default'`, `rule?: { index: number, reason?: string }`, `args?: Record`, `at: string` | How a tool call's [permission rules](./approvals.md#permission-policies) decided it: after its `tool.start`, before the call's own `tool.done` / `tool.error` / `approval.requested`. Only when the run sets `permissions` or `onPermissionDecision`; `args` is left out under `redactContent`. | | `step.done` | `step: number`, `finishReason: string`, `usage?: { promptTokens, completionTokens, totalTokens }` | A step ends. `finishReason` is the model's (`'stop'`, `'tool_calls'`, `'length'`, ...), or `'awaiting-approval'`, `'aborted'` or `'error'` when the step ended that way (a run that runs out of `maxSteps` still wanting to continue ends with `run.done` `'max-steps'`). `usage` is this step's model call, absent when the call produced no response. | | `error` | `error: { name: string, message: string }` | An error. If it ends the run, `run.done` with `finishReason: 'error'` follows. A provider error retried under `surfaceRetryableProviderErrors` is followed by further steps instead. | | `provider.retry` | `attempt: number`, `maxRetries: number`, `delayMs: number`, `error: { message: string, category?: string }`, `provider: string` | A model call failed and is retried after `delayMs` (`createAgent({ retry })` or any `withRetry()` provider). `attempt` is the attempt that failed (1 = first); `category` is `'rate-limit'`, `'timeout'`, ... and absent when unknown (a 5xx, for example). | diff --git a/docs/tools.md b/docs/tools.md index d00f51279..909204b9b 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -96,7 +96,7 @@ check works everywhere (including the call that runs after an approval): - `error`: the error's name (`TypeError`, `ToolArgumentsValidationError`, ...), or the kind's default name for a failure that is not a thrown error. - `toolName` and `message`: the message only, never a stack, capped at 2,000 characters (`... (truncated)` marks a cut). - `kind`: why the call failed. -- Some kinds add fields: `issues` for `validation`, `note` for `rejected`. +- Some kinds add fields: `issues` for `validation`, `note` for `rejected`, `reason` for `denied`. The transcript message carries `isError: true`; `tool-result` events, `onToolResult`, `postToolCall` hooks and `tool.error` events see the call as failed. @@ -109,6 +109,7 @@ The transcript message carries `isError: true`; `tool-result` events, `onToolRes | `not-run` | `ToolNotRunError` | The call was never started (a resumed run whose approval was saved without its remaining calls). | | `mcp` | `McpToolError` | An MCP server answered `isError: true`; `message` is the server's text. | | `sandbox` | `SandboxRequiredError` | The tool has `requiresSandbox` but no `sandboxExecute`, so it was refused rather than run unsandboxed. | +| `denied` | `ToolDeniedError` | A `deny` [permission rule](./approvals.md#permission-policies) refused the call; `execute` did not run. Adds `reason` when the rule has one. | `toolErrorResult({ toolName, error, kind?, toolCallId?, details? })` builds this result; use it in your own tool wrappers so they match. A thrown error can pick diff --git a/llms-full.txt b/llms-full.txt index c9ce9b672..6cf435092 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -69,7 +69,7 @@ agent can be an `agent.yaml` spec served with `npx loushy dev agent.yaml` ## Features - **Tools**: `defineTool()` with a zod `input`; arguments are typed, validated, and run in parallel. [Tools](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/tools.md) -- **Approvals**: `needsApproval` pauses a run; `agent.approvals.resolve()` continues it, or `approve` decides in code. [Approvals](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/approvals.md) +- **Approvals and permission policies**: `needsApproval` pauses a run; `agent.approvals.resolve()` continues it, or `approve` decides in code. `permissions` rules allow, deny or ask per tool before that, with an audit log. [Approvals](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/approvals.md) - **Sessions**: `agent.session()` keeps a multi-turn conversation in memory, files or SQLite. [Sessions](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sessions.md) - **Structured output**: `output: zodSchema` makes the final reply a typed, validated `result.object`, with one repair step. [Structured output](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/structured-output.md) - **Streaming**: `agent.stream()` and `session.stream()` yield typed, versioned JSON events ready for SSE. [Streaming](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/streaming.md) @@ -1861,6 +1861,73 @@ approval stops the batch: the calls before it run, the run pauses on it, and the calls after it run once it is decided (see [Approvals in the middle of a tool batch](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/durable-execution.md#approvals-in-the-middle-of-a-tool-batch)). +## Permission policies + +`permissions` sets rules for the whole agent instead of tool by tool: a list of +`{ tool, when?, action, reason? }` rules, checked in order for every tool call +before the tool's own `needsApproval`. The first rule that matches decides: + +- `allow` runs the call, without approval even if `needsApproval` would ask. +- `deny` does not run it. The model gets a tool error with `kind: 'denied'` + and the rule's `reason` (see [Tool errors](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/tools.md)), and streams see + `tool.error`. +- `ask` pauses the run for approval, exactly like `needsApproval` (or asks the + `approve` callback). + +When no rule matches, the tool's own `needsApproval` decides, as before. +`tool` is a name, a list of names, a `RegExp` tested against the name, or +`'*'` for every tool. `when` narrows a rule to some calls: it gets the +validated arguments (after `preToolCall` hooks) and `{ toolName, toolCallId, +sessionId }`, and may be async; when it throws, the call fails with that +error. `allow(tools)`, `deny(tools, reason?)` and `ask(tools)` build the +common rules. + +```ts +import { allow, ask, createAgent, defineTool, deny, type PermissionRule } from '@loushy/build-ai-agent'; +import { z } from 'zod'; + +const shell = defineTool({ + name: 'shell', + description: 'Run a shell command', + input: z.object({ command: z.string() }), + execute: async ({ command }) => `ran ${command}`, +}); + +const noDeletes: PermissionRule = { + tool: 'shell', + when: (args) => /\brm\b/.test(String(args.command)), + action: 'deny', + reason: 'Deleting files is not allowed', +}; + +const agent = createAgent({ + provider, + instructions: 'You are a coding assistant.', + tools: [shell], + permissions: [ + noDeletes, // first match wins: this beats the `ask` below + allow(/^read_/), // read-only tools never pause + deny('delete_file', 'Use the trash tool instead'), + ask(['shell', 'write_file']), + ], + onPermissionDecision: (entry) => console.log(entry.at, entry.toolName, entry.decision, entry.rule?.reason), +}); +``` + +`onPermissionDecision` is the audit log: it is called once per tool call +(except calls already refused for invalid arguments) with +`{ toolName, toolCallId, decision, rule?, args?, at }`. `decision` is the +matching rule's action or `'default'` when none matched, `rule` is that rule's +`{ index, reason? }`, `at` is an ISO timestamp, and `args` is left out when the +run sets `redactContent`. Streams get the same entry as a `permission.decision` +event (see [Streaming](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/streaming.md)). Both are only produced when the agent +sets `permissions` or `onPermissionDecision`. + +Both options also exist on `AgentExecutor.execute()` / `stream()` and +`resumeAfterApproval()`. Sub-agents inherit the lead agent's rules, checked +before the sub-agent's own, and report their decisions to the lead's +`onPermissionDecision`. + ## `createAgent()` agents - A paused `send()` resolves (it does not throw) with @@ -4922,6 +4989,7 @@ The event types and their extra fields: | `tool.done` | `toolCallId`, `toolName`, `result: unknown`, `durationMs: number` | A tool call returned. `result` is the value as it would be JSON-encoded (`undefined` becomes `null`, a `Date` becomes a string). `durationMs` counts from its `tool.start`. | | `tool.error` | `toolCallId`, `toolName`, `error: { name: string, message: string }`, `durationMs: number` | A tool call failed: it threw, its arguments did not match its schema (`name: 'ToolArgumentsValidationError'`), or the tool does not exist. The model gets the error as the call's result and the run continues. | | `approval.requested` | `approvalId: string`, `toolCallId`, `toolName`, `args: Record` | A tool call needs a human decision. The run then stops; resume it with `agent.approvals.resolve()` or `resumeAfterApproval()` (see [Approvals](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/approvals.md)). | +| `permission.decision` | `toolCallId`, `toolName`, `decision: 'allow' \| 'deny' \| 'ask' \| 'default'`, `rule?: { index: number, reason?: string }`, `args?: Record`, `at: string` | How a tool call's [permission rules](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/approvals.md#permission-policies) decided it: after its `tool.start`, before the call's own `tool.done` / `tool.error` / `approval.requested`. Only when the run sets `permissions` or `onPermissionDecision`; `args` is left out under `redactContent`. | | `step.done` | `step: number`, `finishReason: string`, `usage?: { promptTokens, completionTokens, totalTokens }` | A step ends. `finishReason` is the model's (`'stop'`, `'tool_calls'`, `'length'`, ...), or `'awaiting-approval'`, `'aborted'` or `'error'` when the step ended that way (a run that runs out of `maxSteps` still wanting to continue ends with `run.done` `'max-steps'`). `usage` is this step's model call, absent when the call produced no response. | | `error` | `error: { name: string, message: string }` | An error. If it ends the run, `run.done` with `finishReason: 'error'` follows. A provider error retried under `surfaceRetryableProviderErrors` is followed by further steps instead. | | `provider.retry` | `attempt: number`, `maxRetries: number`, `delayMs: number`, `error: { message: string, category?: string }`, `provider: string` | A model call failed and is retried after `delayMs` (`createAgent({ retry })` or any `withRetry()` provider). `attempt` is the attempt that failed (1 = first); `category` is `'rate-limit'`, `'timeout'`, ... and absent when unknown (a 5xx, for example). | @@ -5998,7 +6066,7 @@ check works everywhere (including the call that runs after an approval): - `error`: the error's name (`TypeError`, `ToolArgumentsValidationError`, ...), or the kind's default name for a failure that is not a thrown error. - `toolName` and `message`: the message only, never a stack, capped at 2,000 characters (`... (truncated)` marks a cut). - `kind`: why the call failed. -- Some kinds add fields: `issues` for `validation`, `note` for `rejected`. +- Some kinds add fields: `issues` for `validation`, `note` for `rejected`, `reason` for `denied`. The transcript message carries `isError: true`; `tool-result` events, `onToolResult`, `postToolCall` hooks and `tool.error` events see the call as failed. @@ -6011,6 +6079,7 @@ The transcript message carries `isError: true`; `tool-result` events, `onToolRes | `not-run` | `ToolNotRunError` | The call was never started (a resumed run whose approval was saved without its remaining calls). | | `mcp` | `McpToolError` | An MCP server answered `isError: true`; `message` is the server's text. | | `sandbox` | `SandboxRequiredError` | The tool has `requiresSandbox` but no `sandboxExecute`, so it was refused rather than run unsandboxed. | +| `denied` | `ToolDeniedError` | A `deny` [permission rule](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/approvals.md#permission-policies) refused the call; `execute` did not run. Adds `reason` when the rule has one. | `toolErrorResult({ toolName, error, kind?, toolCallId?, details? })` builds this result; use it in your own tool wrappers so they match. A thrown error can pick diff --git a/src/createAgent.ts b/src/createAgent.ts index bcf24f7eb..abbd01c96 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -40,6 +40,7 @@ import { InMemoryApprovalStore } from './execution/InMemoryApprovalStore'; import { resumeAfterApproval } from './execution/resume'; import { ConfigurationError } from './execution/errors'; import { createAgentApprovals, type AgentApprovals, type ApproveToolCall } from './createAgentApprovals'; +import type { PermissionOptions } from './execution/permissions'; import type { z } from 'zod'; import type { McpServerSpec } from './spec/schema'; import { agentMcp, streamAfter } from './tools/mcp/agentMcp'; @@ -52,7 +53,7 @@ import { compactionHookFor, type AgentCompaction } from './context/agentCompacti * AgentConfig.tools) will refer to them by - createAgent() registers each * one into a fresh ToolRegistry under that key. */ -export interface CreateAgentBase { +export interface CreateAgentBase extends PermissionOptions { /** * Optional tools: an array of `defineTool()` results (named by the tool), * or a record of descriptors keyed by the name the agent should call them by. @@ -458,6 +459,9 @@ export function createAgent( if (config.subagents) assertNoTaskTool(agent, toolRegistry); const runOptions = { + // LOU-X2: also used by resumed runs and when this agent is a sub-agent. + permissions: config.permissions, + onPermissionDecision: config.onPermissionDecision, skills: config.skills, subagents: subagentsWithOptions(config.subagents, config.subagentOptions), maxSubagentDepth: config.maxSubagentDepth, diff --git a/src/execution/AgentExecutor.ts b/src/execution/AgentExecutor.ts index 3c84da4d4..e46e89bc6 100644 --- a/src/execution/AgentExecutor.ts +++ b/src/execution/AgentExecutor.ts @@ -68,6 +68,7 @@ import { } from './agentRunState'; import { AgentRun, RUN_EVENTS, StreamingExecuteOptions, runEventsOf, startAgentRun } from './agentRun'; import { OutputError, outputInstruction, outputRepairMessage, validateOutput } from './structuredOutput'; +import type { PermissionOptions } from './permissions'; export { PropagatingToolError } from './propagatingToolError'; @@ -150,7 +151,7 @@ export interface ExecutionEvent { /** * Execution options */ -export interface ExecuteOptions { +export interface ExecuteOptions extends PermissionOptions { agent: AgentConfig; input: string | Message[]; provider: LLMProvider; diff --git a/src/execution/agentEvents.test-d.ts b/src/execution/agentEvents.test-d.ts index 7f0447790..c42dcb32c 100644 --- a/src/execution/agentEvents.test-d.ts +++ b/src/execution/agentEvents.test-d.ts @@ -66,6 +66,14 @@ describe('AgentEvent types', () => { if (event.type === 'provider.fallback') expectTypeOf(event.error).toEqualTypeOf<{ message: string }>(); }); + it('narrows permission.decision on event.type (LOU-X2)', () => { + if (event.type === 'permission.decision') { + expectTypeOf(event.decision).toEqualTypeOf<'allow' | 'deny' | 'ask' | 'default'>(); + expectTypeOf(event.rule).toEqualTypeOf<{ index: number; reason?: string } | undefined>(); + expectTypeOf(event.at).toBeString(); + } + }); + it('narrows compaction events on event.type (LOU-W3.2)', () => { if (event.type === 'compaction.start') expectTypeOf(event.thresholdTokens).toBeNumber(); if (event.type === 'compaction.done') { @@ -85,6 +93,7 @@ describe('AgentEvent types', () => { | 'tool.done' | 'tool.error' | 'approval.requested' + | 'permission.decision' | 'step.done' | 'error' | 'provider.retry' diff --git a/src/execution/agentEvents.ts b/src/execution/agentEvents.ts index bd63ea629..e20a307ef 100644 --- a/src/execution/agentEvents.ts +++ b/src/execution/agentEvents.ts @@ -10,6 +10,7 @@ import type { ExecutionFinishReason } from './AgentExecutor'; import type { SubagentInfo } from './hooks'; import type { CompactedProviderErrorCategory } from './errors'; +import type { PermissionDecisionEntry } from './permissions'; /** * Version of the {@link AgentEvent} schema, carried on every event as `v`. @@ -129,6 +130,14 @@ export interface ApprovalRequestedEvent extends AgentEventBase<'approval.request args: Record; } +/** + * LOU-X2: how a tool call's permission was decided - by the first matching + * rule of `permissions` (`allow`, `deny`, `ask`) or `'default'` when none + * matched. Emitted after `tool.start` when the run sets `permissions` or + * `onPermissionDecision`; carries the same audit entry `onPermissionDecision` gets. + */ +export interface PermissionDecisionEvent extends AgentEventBase<'permission.decision'>, PermissionDecisionEntry {} + /** A step ends. Every `step.start` is followed by exactly one `step.done`. */ export interface StepDoneEvent extends AgentEventBase<'step.done'> { step: number; @@ -250,6 +259,7 @@ export type AgentEvent = | ToolDoneEvent | ToolErrorEvent | ApprovalRequestedEvent + | PermissionDecisionEvent | StepDoneEvent | AgentErrorEvent | ProviderRetryEvent @@ -285,6 +295,7 @@ const EVENT_TYPES: ReadonlySet = new Set([ 'tool.done', 'tool.error', 'approval.requested', + 'permission.decision', 'step.done', 'error', 'provider.retry', diff --git a/src/execution/agentRun.ts b/src/execution/agentRun.ts index 215289e18..d0807e3ac 100644 --- a/src/execution/agentRun.ts +++ b/src/execution/agentRun.ts @@ -26,7 +26,8 @@ import { AgentEventPayload, AgentEventUsage, } from './agentEvents'; -import { parseToolArguments } from './toolCallExecution'; +import { parseToolArguments } from './toolArgsValidation'; +import type { PermissionDecisionEntry } from './permissions'; import { canStream, generateViaStream } from './streamStep'; import { measureUsage } from './runUsage'; import { compactProviderError } from './errors'; @@ -72,6 +73,8 @@ export interface RunEventSink { /** `finishReason` overrides the step's own (the model's) finish reason. */ stepDone(step: number, finishReason?: string): void; approvalRequested(pending: PendingApproval): void; + /** LOU-X2: a tool call's permission decision. */ + permissionDecision(entry: PermissionDecisionEntry): void; /** LOU-W3.2: an event a hook emits (`GenerateHookContext.emit`). */ hookEvent(event: HookEventPayload): void; /** Obtains one model step - streamed when the provider can. */ @@ -336,6 +339,11 @@ class AgentRunImpl implements AgentRun { args: toJsonValue(pending.args) as Record, }); }, + permissionDecision: (entry) => + this.emit( + { type: 'permission.decision', ...entry, ...(entry.args && { args: toJsonValue(entry.args) as Record }) }, + subagent + ), hookEvent: (event) => this.emit(event, subagent), generate: async (provider, request) => { const call = withProviderEvents(request, this.providerEvents(subagent)); diff --git a/src/execution/delegation.ts b/src/execution/delegation.ts index e4be8494c..437a6c3be 100644 --- a/src/execution/delegation.ts +++ b/src/execution/delegation.ts @@ -22,6 +22,7 @@ import type { ExecuteOptions, ExecutionEvent, ExecutionResult } from './AgentExe import type { ResumeExecuteOptions } from './resume'; import type { ApprovalStore, ExecutionSnapshot } from './ApprovalGate'; import type { ToolConcurrency } from './toolBatch'; +import type { PermissionRule } from './permissions'; import { HookRegistry, type AgentHook, type HookContext, type SubagentInfo } from './hooks'; import { markPropagating } from './propagatingToolError'; import { SubagentApprovalPause, subagentBudget, toolCallScopeOf, type ToolCallScope } from './subagentRuntime'; @@ -38,6 +39,8 @@ export interface SubagentSpec { maxSubagentDepth?: number; maxSteps?: number; toolConcurrency?: ToolConcurrency; + /** LOU-X2: the sub-agent's own permission rules, checked after the ones it inherits. */ + permissions?: readonly PermissionRule[]; } /** One child run requested by a parent tool call. */ @@ -157,6 +160,12 @@ function childOptions( // The top-level run's limit bounds the whole tree: one level used up here. maxSubagentDepth: Math.max(0, subagentBudget(runtime.maxSubagentDepth) - 1), maxSteps: spec.maxSteps, + // LOU-X2: the parent's rules come first, so they win over the sub-agent's own. + permissions: + runtime.permissions && spec.permissions + ? [...runtime.permissions, ...spec.permissions] + : (runtime.permissions ?? spec.permissions), + onPermissionDecision: runtime.onPermissionDecision, }; } diff --git a/src/execution/index.ts b/src/execution/index.ts index 7118829d1..2e7b8ebb9 100644 --- a/src/execution/index.ts +++ b/src/execution/index.ts @@ -22,6 +22,16 @@ export { ToolArgumentsValidationError } from './toolArgsValidation'; export type { ToolArgumentIssue } from './toolArgsValidation'; export { toolErrorResult } from './toolErrors'; export type { ToolErrorKind, ToolErrorResult, ToolErrorInput } from './toolErrors'; +export { allow, ask, deny } from './permissions'; +export type { + PermissionAction, + PermissionContext, + PermissionDecision, + PermissionDecisionEntry, + PermissionOptions, + PermissionRule, + PermissionToolMatcher, +} from './permissions'; export type { OutputError } from './structuredOutput'; export type { ToolConcurrency } from './toolBatch'; export { @@ -46,6 +56,7 @@ export type { ToolDoneEvent, ToolErrorEvent, ApprovalRequestedEvent, + PermissionDecisionEvent, StepDoneEvent, AgentErrorEvent, ProviderRetryEvent, diff --git a/src/execution/permissions.test.ts b/src/execution/permissions.test.ts new file mode 100644 index 000000000..b5030a7e3 --- /dev/null +++ b/src/execution/permissions.test.ts @@ -0,0 +1,257 @@ +/** + * LOU-X2: declarative permission policies - ordered allow/deny/ask rules + * checked before a tool's `needsApproval`, with an audit trail. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { z } from 'zod'; +import { createAgent } from '../createAgent'; +import { defineTool } from '../tools/defineTool'; +import { ToolRegistry } from '../tools'; +import { mockModel, type MockModel } from '../testing'; +import type { AgentConfig } from '../types'; +import type { AgentEvent, AgentEventOf } from './agentEvents'; +import { AgentExecutor } from './AgentExecutor'; +import { allow, ask, deny, type PermissionDecisionEntry, type PermissionRule } from './permissions'; + +/** A tool named `name` that records its calls; `needsApproval` as given. */ +function tool(name: string, needsApproval = false) { + const calls: unknown[] = []; + const defined = defineTool({ + name, + description: `The ${name} tool`, + input: z.object({ value: z.string().optional() }), + needsApproval, + execute: async (args) => { + calls.push(args); + return `${name} ran`; + }, + }); + return { defined, calls }; +} + +/** The tool results the model saw on its second call, parsed. */ +function toolResults(model: MockModel): Array> { + return model.calls[1].messages + .filter((m) => m.role === 'tool') + .map((m) => JSON.parse(String(m.content)) as Record); +} + +async function collect(run: AsyncIterable): Promise { + const events: AgentEvent[] = []; + for await (const event of run) events.push(event); + return events; +} + +const decisions = (events: AgentEvent[]): AgentEventOf<'permission.decision'>[] => + events.filter((e): e is AgentEventOf<'permission.decision'> => e.type === 'permission.decision'); + +describe('permission policies (LOU-X2)', () => { + it('allow runs a needsApproval tool without pausing', async () => { + const email = tool('send_email', true); + const agent = createAgent({ + provider: mockModel([{ toolCalls: [{ name: 'send_email' }] }, 'sent']), + tools: [email.defined], + permissions: [allow('send_email')], + }); + + const result = await agent.send('go'); + + expect(result.finishReason).toBe('stop'); + expect(email.calls).toHaveLength(1); + expect(await agent.approvals.list()).toEqual([]); + }); + + it('deny gives the model a structured denied error with the reason and emits tool.error', async () => { + const shell = tool('shell'); + const model = mockModel([{ toolCalls: [{ name: 'shell', id: 'c1' }] }, 'ok']); + const agent = createAgent({ provider: model, tools: [shell.defined], permissions: [deny('shell', 'No shell in prod')] }); + + const events = await collect(agent.stream('go')); + + expect(shell.calls).toHaveLength(0); + expect(toolResults(model)).toEqual([ + expect.objectContaining({ error: 'ToolDeniedError', kind: 'denied', toolName: 'shell', reason: 'No shell in prod' }), + ]); + expect(String(toolResults(model)[0].message)).toContain('No shell in prod'); + expect(events.find((e) => e.type === 'tool.error')).toMatchObject({ + toolCallId: 'c1', + error: { name: 'ToolDeniedError', message: "Tool 'shell' was denied by a permission rule: No shell in prod" }, + }); + expect(decisions(events)).toEqual([ + expect.objectContaining({ toolCallId: 'c1', toolName: 'shell', decision: 'deny', rule: { index: 0, reason: 'No shell in prod' } }), + ]); + const types = events.map((e) => e.type); + expect(types.indexOf('tool.start')).toBeLessThan(types.indexOf('permission.decision')); + }); + + it('ask pauses a tool that does not need approval, and the run resumes when approved', async () => { + const write = tool('write_file'); + const agent = createAgent({ + provider: mockModel([{ toolCalls: [{ name: 'write_file' }] }, 'written']), + tools: [write.defined], + permissions: [ask('write_file')], + }); + + const paused = await agent.send('go'); + expect(paused.finishReason).toBe('awaiting-approval'); + expect(write.calls).toHaveLength(0); + + const result = await agent.approvals.resolve({ id: paused.approvalId!, approved: true }); + expect(result.text).toBe('written'); + expect(write.calls).toHaveLength(1); + }); + + it('ask goes through the approve callback when one is set', async () => { + const write = tool('write_file'); + const approve = vi.fn(() => false); + const agent = createAgent({ + provider: mockModel([{ toolCalls: [{ name: 'write_file' }] }, 'not written']), + tools: [write.defined], + permissions: [ask('*')], + approve, + }); + + const result = await agent.send('go'); + + expect(approve).toHaveBeenCalledTimes(1); + expect(result.text).toBe('not written'); + expect(write.calls).toHaveLength(0); + }); + + it('matches tools by name list and by pattern', async () => { + const tools = ['read_a', 'read_b', 'danger_rm', 'other'].map((name) => tool(name, true)); + const audit: PermissionDecisionEntry[] = []; + const agent = createAgent({ + provider: mockModel([{ toolCalls: tools.map((t) => ({ name: t.defined.name })) }, 'done']), + tools: tools.map((t) => t.defined), + permissions: [deny(/^danger_/), allow(['read_a', 'read_b'])], + onPermissionDecision: (entry) => audit.push(entry), + }); + + const result = await agent.send('go'); + + expect(audit.map((e) => [e.toolName, e.decision])).toEqual([ + ['read_a', 'allow'], + ['read_b', 'allow'], + ['danger_rm', 'deny'], + ['other', 'default'], + ]); + // `other` matched no rule, so its own needsApproval pauses the run. + expect(result.finishReason).toBe('awaiting-approval'); + expect(tools.map((t) => t.calls.length)).toEqual([1, 1, 0, 0]); + }); + + it('a rule applies only when its async `when` predicate returns true', async () => { + const shell = tool('shell'); + const when = vi.fn(async (args: Record) => String(args.value).startsWith('rm')); + const model = mockModel([ + { toolCalls: [{ name: 'shell', args: { value: 'rm -rf /' } }, { name: 'shell', args: { value: 'ls' } }] }, + 'done', + ]); + const agent = createAgent({ + provider: model, + tools: [shell.defined], + permissions: [{ tool: 'shell', when, action: 'deny', reason: 'No deletes' }], + }); + + await agent.send('go'); + + expect(shell.calls).toEqual([{ value: 'ls' }]); + expect(toolResults(model)[0]).toMatchObject({ kind: 'denied', reason: 'No deletes' }); + expect(when).toHaveBeenCalledWith({ value: 'rm -rf /' }, { toolName: 'shell', toolCallId: 'call_1', sessionId: undefined }); + }); + + it('a throwing `when` becomes the call error, like a throwing needsApproval', async () => { + const shell = tool('shell'); + const model = mockModel([{ toolCalls: [{ name: 'shell' }] }, 'done']); + const rule: PermissionRule = { tool: '*', action: 'allow', when: () => { throw new Error('policy store down'); } }; + const agent = createAgent({ provider: model, tools: [shell.defined], permissions: [rule] }); + + await agent.send('go'); + + expect(shell.calls).toHaveLength(0); + expect(toolResults(model)[0]).toMatchObject({ kind: 'execution', message: 'policy store down' }); + }); + + it('the first matching rule wins', async () => { + const [a, b] = [tool('a', true), tool('b')]; + const audit: PermissionDecisionEntry[] = []; + const agent = createAgent({ + provider: mockModel([{ toolCalls: [{ name: 'a' }, { name: 'b' }] }, 'done']), + tools: [a.defined, b.defined], + permissions: [allow('a'), deny('*', 'Only a'), allow('b')], + onPermissionDecision: (entry) => audit.push(entry), + }); + + await agent.send('go'); + + expect(a.calls).toHaveLength(1); + expect(b.calls).toHaveLength(0); + expect(audit.map((e) => [e.toolName, e.decision, e.rule?.index])).toEqual([ + ['a', 'allow', 0], + ['b', 'deny', 1], + ]); + }); + + it('writes an audit entry per call, with the args unless redactContent is set', async () => { + const lookup = tool('lookup'); + const entries = async (redactContent: boolean) => { + const audit: PermissionDecisionEntry[] = []; + const toolRegistry = new ToolRegistry(); + toolRegistry.registerMany([lookup.defined]); + const agent: AgentConfig = { id: 'a1', name: 'A', prompt: 'p', tools: { lookup: { tool: 'lookup' } } }; + await AgentExecutor.execute({ + agent, + toolRegistry, + input: 'go', + provider: mockModel([{ toolCalls: [{ name: 'lookup', id: 'x1', args: { value: 'secret' } }] }, 'done']), + onPermissionDecision: (entry) => audit.push(entry), + redactContent, + }); + return audit; + }; + + const [entry] = await entries(false); + expect(entry).toEqual({ toolName: 'lookup', toolCallId: 'x1', decision: 'default', args: { value: 'secret' }, at: expect.any(String) }); + expect(new Date(entry.at).toISOString()).toBe(entry.at); + expect(await entries(true)).toEqual([{ toolName: 'lookup', toolCallId: 'x1', decision: 'default', at: expect.any(String) }]); + }); + + it('streams no permission.decision events when no policy is configured', async () => { + const lookup = tool('lookup'); + const agent = createAgent({ provider: mockModel([{ toolCalls: [{ name: 'lookup' }] }, 'done']), tools: [lookup.defined] }); + + expect(decisions(await collect(agent.stream('go')))).toEqual([]); + }); + + it("sub-agents inherit the parent's rules, ahead of their own", async () => { + const lookup = tool('lookup'); + const fetchPage = tool('fetch_page'); + const researcher = createAgent({ + name: 'researcher', + description: 'Researches', + provider: mockModel([{ toolCalls: [{ name: 'lookup' }, { name: 'fetch_page' }] }, 'found nothing']), + tools: [lookup.defined, fetchPage.defined], + permissions: [allow('*')], + }); + const audit: PermissionDecisionEntry[] = []; + const lead = createAgent({ + provider: mockModel([{ toolCalls: [{ name: 'task', args: { agent: 'researcher', prompt: 'look', description: 'look' } }] }, 'done']), + subagents: { researcher }, + permissions: [deny('lookup', 'Not for sub-agents')], + onPermissionDecision: (entry) => audit.push(entry), + }); + + const events = await collect(lead.stream('go')); + + expect(lookup.calls).toHaveLength(0); + expect(fetchPage.calls).toHaveLength(1); + expect(audit.map((e) => [e.toolName, e.decision])).toEqual([ + ['task', 'default'], + ['lookup', 'deny'], + ['fetch_page', 'allow'], + ]); + expect(decisions(events).filter((e) => e.subagent).map((e) => e.toolName)).toEqual(['lookup', 'fetch_page']); + }); +}); diff --git a/src/execution/permissions.ts b/src/execution/permissions.ts new file mode 100644 index 000000000..5c66dc951 --- /dev/null +++ b/src/execution/permissions.ts @@ -0,0 +1,152 @@ +/** + * LOU-X2: declarative permission policies. An ordered list of + * {@link PermissionRule}s is checked for every tool call, after argument + * validation and the `preToolCall` hooks and before the tool's own + * `needsApproval`: the first rule that matches decides whether the call runs + * (`allow`), is refused (`deny`) or waits for a human (`ask`). No match keeps + * the tool's own behaviour. + */ + +import type { ExecuteOptions } from './AgentExecutor'; +import { runEventsOf } from './agentRun'; + +/** What a matching {@link PermissionRule} does with a tool call. */ +export type PermissionAction = 'allow' | 'deny' | 'ask'; + +/** + * Which tools a rule covers: a name, a list of names, a pattern tested + * against the name, or `'*'` for every tool. + */ +export type PermissionToolMatcher = string | readonly string[] | RegExp; + +/** The call a {@link PermissionRule}'s `when` predicate is asked about. */ +export interface PermissionContext { + toolName: string; + toolCallId: string; + sessionId?: string; +} + +/** + * One permission rule. Rules are checked in order; the first whose `tool` + * matches and whose `when` (if any) returns true decides the call. + * + * @example + * ```ts + * import type { PermissionRule } from '@loushy/build-ai-agent'; + * + * const rules: PermissionRule[] = [ + * { tool: 'shell', when: (args) => String(args.command).startsWith('rm '), action: 'deny', reason: 'No deletes' }, + * { tool: /^read_/, action: 'allow' }, + * ]; + * ``` + */ +export interface PermissionRule { + tool: PermissionToolMatcher; + /** Narrows the rule to some calls; it receives the validated (hook-processed) arguments. */ + when?: (args: Record, ctx: PermissionContext) => boolean | Promise; + action: PermissionAction; + /** Why: sent to the model with a `deny`, and recorded in the audit entry. */ + reason?: string; +} + +/** How a tool call's permission was decided: by a rule's action, or `'default'` when no rule matched. */ +export type PermissionDecision = PermissionAction | 'default'; + +/** One audit entry, as passed to `onPermissionDecision` and streamed as `permission.decision`. */ +export interface PermissionDecisionEntry { + toolName: string; + toolCallId: string; + decision: PermissionDecision; + /** The rule that decided (its position in `permissions`); absent for `'default'`. */ + rule?: { index: number; reason?: string }; + /** The call's arguments; omitted when the run sets `redactContent`. */ + args?: Record; + /** When the decision was made, as an ISO-8601 string. */ + at: string; +} + +/** Permission options of `createAgent()` and `AgentExecutor.execute()`. */ +export interface PermissionOptions { + /** + * Permission rules (LOU-X2), checked in order before each tool's + * `needsApproval`. The first match decides: `allow` runs the call without + * approval, `deny` gives the model a `kind: 'denied'` tool error with the + * rule's `reason`, `ask` pauses the run for approval (or asks `approve`). + * No match keeps today's behaviour. Sub-agents inherit them. + * + * @example + * ```ts + * import { allow, ask, deny } from '@loushy/build-ai-agent'; + * + * const permissions = [deny('delete_file', 'Deleting is not allowed'), ask(['shell', 'write_file']), allow('*')]; + * ``` + */ + permissions?: readonly PermissionRule[]; + /** Called with an audit entry for every tool call's permission decision (LOU-X2). */ + onPermissionDecision?: (entry: PermissionDecisionEntry) => void; +} + +/** A rule that runs the matching tools without approval. */ +export function allow(tools: PermissionToolMatcher): PermissionRule { + return { tool: tools, action: 'allow' }; +} + +/** A rule that refuses the matching tools; `reason` is sent to the model. */ +export function deny(tools: PermissionToolMatcher, reason?: string): PermissionRule { + return { tool: tools, action: 'deny', ...(reason !== undefined && { reason }) }; +} + +/** A rule that pauses the matching tools for approval, even when they do not set `needsApproval`. */ +export function ask(tools: PermissionToolMatcher): PermissionRule { + return { tool: tools, action: 'ask' }; +} + +function matchesTool(matcher: PermissionToolMatcher, toolName: string): boolean { + if (typeof matcher === 'string') return matcher === '*' || matcher === toolName; + if (matcher instanceof RegExp) return toolName.search(matcher) !== -1; + return matcher.some((name) => matchesTool(name, toolName)); +} + +/** The run options a permission check reads. */ +type PermissionRuntime = Pick; + +/** Index of the first rule that matches the call, or -1. */ +async function firstMatch( + rules: readonly PermissionRule[], + args: Record, + ctx: PermissionContext +): Promise { + for (const [index, rule] of rules.entries()) { + if (matchesTool(rule.tool, ctx.toolName) && (!rule.when || (await rule.when(args, ctx)))) return index; + } + return -1; +} + +/** + * Checks `call` against the run's permission rules, reports the decision + * (`onPermissionDecision` and, for a streaming run, a `permission.decision` + * event) and returns it. Returns undefined - nothing checked, nothing + * reported - when the run sets neither `permissions` nor + * `onPermissionDecision`. A throwing `when` propagates. + */ +export async function checkPermission( + runtime: PermissionRuntime, + call: PermissionContext & { args: Record } +): Promise { + const { permissions = [], onPermissionDecision, redactContent } = runtime; + if (!runtime.permissions && !onPermissionDecision) return undefined; + const { args, ...ctx } = call; + const index = await firstMatch(permissions, args, ctx); + const rule = permissions[index] as PermissionRule | undefined; + const entry: PermissionDecisionEntry = { + toolName: ctx.toolName, + toolCallId: ctx.toolCallId, + decision: rule?.action ?? 'default', + ...(rule && { rule: { index, ...(rule.reason !== undefined && { reason: rule.reason }) } }), + ...(!redactContent && { args }), + at: new Date().toISOString(), + }; + onPermissionDecision?.(entry); + runEventsOf(runtime as ExecuteOptions)?.permissionDecision(entry); + return entry; +} diff --git a/src/execution/subagentRuntime.ts b/src/execution/subagentRuntime.ts index 009ba0748..86236331b 100644 --- a/src/execution/subagentRuntime.ts +++ b/src/execution/subagentRuntime.ts @@ -38,6 +38,8 @@ export type InheritedRuntime = Pick< | 'signal' | 'onEvent' | 'maxSubagentDepth' + | 'permissions' + | 'onPermissionDecision' >; /** What the executor knows about the tool call that is running. */ diff --git a/src/execution/toolArgsValidation.ts b/src/execution/toolArgsValidation.ts index 16571d8ef..c7728b097 100644 --- a/src/execution/toolArgsValidation.ts +++ b/src/execution/toolArgsValidation.ts @@ -4,10 +4,23 @@ */ import { ToolDescriptor } from '../types'; +import type { ToolCall } from '../providers'; import { ToolExecutionError } from './errors'; import { getToolInputSchema } from '../tools/toolContract'; import { toolErrorResult, type ToolErrorResult } from './toolErrors'; +/** + * Best-effort parse of a tool call's JSON `arguments`, returning + * `fallback` when they are not valid JSON. + */ +export function parseToolArguments(toolCall: ToolCall, fallback: unknown): unknown { + try { + return JSON.parse(toolCall.function.arguments); + } catch { + return fallback; + } +} + /** One problem found while validating a tool call's arguments. */ export interface ToolArgumentIssue { /** Dot-joined path to the offending argument (`(root)` for the whole value). */ diff --git a/src/execution/toolCallExecution.ts b/src/execution/toolCallExecution.ts index 9cc871ecb..1d9879533 100644 --- a/src/execution/toolCallExecution.ts +++ b/src/execution/toolCallExecution.ts @@ -14,11 +14,14 @@ import type { ToolRunContext } from './toolRunContext'; import { HookRegistry, ToolCallHookContext } from './hooks'; import { toolErrorMessage } from './propagatingToolError'; import { toolErrorResult, type ToolErrorKind } from './toolErrors'; -import { ToolArgumentsValidationError, validateToolArguments } from './toolArgsValidation'; +import { ToolArgumentsValidationError, parseToolArguments, validateToolArguments } from './toolArgsValidation'; +import { checkPermission } from './permissions'; import type { ExecuteOptions } from './AgentExecutor'; import type { SubagentSuspension } from './ApprovalGate'; import { SubagentApprovalPause, suspendedToolResult, toSuspension, type ToolCallScope } from './subagentRuntime'; +export { parseToolArguments }; + /** * Settled outcome of one tool call, as emitted in the 'tool-result' event * and handed to onToolResult. @@ -55,18 +58,6 @@ export interface ToolCallContext { onDelegatedUsage?: ToolRunContext['onDelegatedUsage']; } -/** - * Best-effort parse of a tool call's JSON `arguments`, returning - * `fallback` when they are not valid JSON. - */ -export function parseToolArguments(toolCall: ToolCall, fallback: unknown): unknown { - try { - return JSON.parse(toolCall.function.arguments); - } catch { - return fallback; - } -} - /** * Builds the context object passed to a pre/post tool-call hook. Called * once per hook invocation so each receives its own object (sharing only @@ -147,8 +138,41 @@ async function prepareToolCall(toolCall: ToolCall, ctx: ToolCallContext): Promis await ctx.hooks.runPreToolCall(toolHookContext(toolCall, ctx, checked.args)); } - const approval = await checkNeedsApproval(toolCall, ctx.toolRegistry, checked.args); - return { toolCall, args: checked.args, ...approval }; + // LOU-X2: a matching permission rule decides before `needsApproval` does. + const gate = + (await checkPermissionRules(toolCall, ctx, checked.args)) ?? + (await checkNeedsApproval(toolCall, ctx.toolRegistry, checked.args)); + return { toolCall, args: checked.args, ...gate }; +} + +/** + * Applies the run's permission rules (LOU-X2, read from `ctx.scope.runtime`). + * Undefined when no rule decided the call; a throwing `when` becomes the + * call's error outcome, like a throwing `needsApproval`. + */ +async function checkPermissionRules( + toolCall: ToolCall, + ctx: ToolCallContext, + args: Record +): Promise | undefined> { + if (!ctx.scope) { + return undefined; + } + const toolName = toolCall.function.name; + try { + const entry = await checkPermission(ctx.scope.runtime, { toolName, toolCallId: toolCall.id, sessionId: ctx.sessionId, args }); + if (entry?.decision === 'deny') { + const reason = entry.rule?.reason; + const error = `Tool '${toolName}' was denied by a permission rule${reason ? `: ${reason}` : ''}`; + const result = toolErrorResult({ toolName, error, kind: 'denied', details: reason ? { reason } : undefined }); + return { requiresApproval: false, rejection: { ...toolFailure(toolCall, 'denied', error), result } }; + } + return entry?.decision === 'allow' || entry?.decision === 'ask' + ? { requiresApproval: entry.decision === 'ask' } + : undefined; + } catch (error) { + return { requiresApproval: false, rejection: thrownToolFailure(toolCall, error) }; + } } /** diff --git a/src/execution/toolErrors.ts b/src/execution/toolErrors.ts index 22709d6fd..0942bb4ec 100644 --- a/src/execution/toolErrors.ts +++ b/src/execution/toolErrors.ts @@ -1,8 +1,8 @@ /** * LOU-U14: the one shape of a failed tool call. Every failure path (a thrown * error, invalid arguments, an unknown tool, a rejected approval, a call that - * was never run, an MCP `isError` result, a failed sandbox guard) hands the - * model the object {@link toolErrorResult} builds. + * was never run, a call a permission rule denied, an MCP `isError` result, a + * failed sandbox guard) hands the model the object {@link toolErrorResult} builds. */ /** Why a tool call produced an error result. */ @@ -13,7 +13,8 @@ export type ToolErrorKind = | 'rejected' | 'not-run' | 'mcp' - | 'sandbox'; + | 'sandbox' + | 'denied'; /** Max characters of an error message sent to the model (see {@link toolErrorResult}). */ const MAX_TOOL_ERROR_MESSAGE_LENGTH = 2000; @@ -27,6 +28,7 @@ const DEFAULT_ERROR_NAME: Record = { 'not-run': 'ToolNotRunError', mcp: 'McpToolError', sandbox: 'SandboxRequiredError', + denied: 'ToolDeniedError', }; /**