feat: rework permission decision policies (#26)

This commit is contained in:
_Kerman 2026-05-27 20:07:24 +08:00 committed by GitHub
parent b7e7404d90
commit 2b74025302
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 4330 additions and 2177 deletions

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code": minor
---
Rework tool permissions: reads outside cwd no longer prompt, session approvals match the exact call, and path-based rules are case-insensitive.

View file

@ -203,7 +203,7 @@ Rules are written as a `[[permission.rules]]` array of tables, where each rule c
| --- | --- | --- | --- |
| `decision` | `string` | Yes | Decision result; one of `allow`, `deny`, `ask` |
| `scope` | `string` | No | Rule scope; one of `turn-override`, `session-runtime`, `project`, `user`; defaults to `user` |
| `pattern` | `string` | Yes | Match pattern in the form `ToolName` or `ToolName(arg-pattern)`. `ToolName` must match the runtime tool name exactly — built-in tools are `Read`, `Write`, `Edit`, `Bash`, `Grep`, and so on (see [Built-in tools](../reference/tools.md)) |
| `pattern` | `string` | Yes | Match pattern in the form `ToolName` or `ToolName(arg-pattern)`. `ToolName` must match the runtime tool name exactly — built-in tools are `Read`, `Write`, `Edit`, `Bash`, `Grep`, and so on (see [Built-in tools](../reference/tools.md)). Argument patterns are interpreted only by tools with built-in argument matchers, such as `Bash`, file tools, and search tools; MCP tools and custom tools match by tool name only |
| `reason` | `string` | No | Rule description for debugging or auditing |
Example:

View file

@ -54,7 +54,7 @@ Stdio entries in a project-level `.kimi-code/mcp.json` execute local commands wh
## Tool naming and permissions
MCP tools are exposed using the naming convention `mcp__<server>__<tool>`. Permission matching supports `*` and `**` wildcards, so `mcp__github__*` covers every tool from the `github` server.
MCP tools are exposed using the naming convention `mcp__<server>__<tool>`. Permission matching supports `*` and `**` wildcards in tool names, so `mcp__github__*` covers every tool from the `github` server. MCP tool arguments are not part of permission matching; allow or deny the exact MCP tool name, or use a tool-name wildcard.
Calls that do not match any rule trigger an approval request. Choosing "Approve for this session" in the approval prompt auto-approves subsequent matching calls.

View file

@ -201,7 +201,7 @@ api_key = "sk-xxx"
| --- | --- | --- | --- |
| `decision` | `string` | 是 | 决策结果,可选 `allow``deny``ask` |
| `scope` | `string` | 否 | 规则作用域,可选 `turn-override``session-runtime``project``user`;默认 `user` |
| `pattern` | `string` | 是 | 匹配模式,格式为 `ToolName``ToolName(arg-pattern)``ToolName` 必须与运行时真实工具名一致——内置工具是 `Read``Write``Edit``Bash``Grep` 等(详见 [内置工具](../reference/tools.md) |
| `pattern` | `string` | 是 | 匹配模式,格式为 `ToolName``ToolName(arg-pattern)``ToolName` 必须与运行时真实工具名一致——内置工具是 `Read``Write``Edit``Bash``Grep` 等(详见 [内置工具](../reference/tools.md)。参数模式只由带内置参数 matcher 的工具解释,例如 `Bash`、文件工具和搜索工具MCP 工具和自定义工具只按工具名匹配 |
| `reason` | `string` | 否 | 规则说明,供调试或审计使用 |
示例:

View file

@ -54,7 +54,7 @@ MCP server 配置写在 `mcp.json` 中,分为两层:
## 工具命名与权限
MCP 工具按 `mcp__<server>__<tool>` 命名。权限匹配支持 `*``**` 通配,例如 `mcp__github__*` 命中该 server 下所有工具。
MCP 工具按 `mcp__<server>__<tool>` 命名。权限匹配支持工具名中的 `*``**` 通配,例如 `mcp__github__*` 命中该 server 下所有工具。MCP 工具参数不会参与权限匹配;请放行或拒绝精确的 MCP 工具名,或使用工具名通配。
未命中权限规则的调用会触发审批请求;在审批弹窗中选择 "Approve for this session" 后,后续同类调用将自动放行。

View file

@ -1,142 +0,0 @@
/**
* describeApprovalAction coarse action label for approve_for_session.
*
* The label is the key used by `auto_approve_actions` set (session-level
* approve-for-session cache). It must be **coarse** enough that the user
* pressing "approve for session" on one request also unlocks *semantically
* equivalent* future requests otherwise approve-for-session degrades
* into approve-once.
*
* Derivation priority:
* 1. `ApprovalDisplay.kind` mapping (the display already carries the
* semantic classification the UI renders):
* command "run command"
* diff "edit file"
* file_write "write file"
* task_stop "stop background task"
* generic tool-name fallback
* 2. Hard-coded toolName action map for tools that emit `generic`
* display.
* 3. Last resort: `call <toolName>`.
*/
import type { ToolInputDisplay } from '../../tools/display/schemas';
/**
* Hard-coded toolName action label map. Consulted as a fallback so
* tools that carry `generic` displays still get a sensible label. Keys
* match the tool names currently wired in
* `packages/agent-core/src/tools/`.
*/
const TOOL_NAME_TO_ACTION: Readonly<Record<string, string>> = {
Bash: 'run command',
Shell: 'run command',
BackgroundRun: 'run background command',
BackgroundStop: 'stop background task',
Write: 'edit file',
Edit: 'edit file',
StrReplace: 'edit file',
};
/** Inverse table — action label → the representative tool-name pattern. */
const ACTION_TO_PATTERN: Readonly<Record<string, string | null>> = {
'run command': 'Bash',
'run command in plan mode': null,
'run background command': 'BackgroundRun',
'stop background task': 'BackgroundStop',
'edit file': 'Write',
'edit file outside of working directory': 'Write',
'write file': 'Write',
};
export function describeApprovalAction(
toolName: string,
_args: unknown,
display: ToolInputDisplay,
override?: string,
): string {
// Highest priority: explicit override from a hook.
if (override !== undefined && override.length > 0) {
return override;
}
// Display-driven derivation: the display kind already captures the
// coarse semantic class the UI renders.
switch (display.kind) {
case 'command':
return 'run command';
case 'diff':
return 'edit file';
case 'file_io':
switch (display.operation) {
case 'write':
return 'write file';
case 'edit':
return 'edit file';
case 'read':
return 'read file';
case 'glob':
return 'list files';
case 'grep':
return 'search files';
}
break;
case 'task_stop':
return 'stop background task';
case 'plan_review':
return 'review plan';
case 'agent_call':
return 'spawn agent';
case 'skill_call':
return 'invoke skill';
case 'url_fetch':
return 'fetch URL';
case 'search':
return 'search';
case 'todo_list':
return 'update todo list';
case 'background_task':
return 'run background task';
case 'generic':
// fall through to tool-name map
break;
}
const mapped = TOOL_NAME_TO_ACTION[toolName];
if (mapped !== undefined) return mapped;
// MCP tool naming: `mcp__<server>__<tool>` gets a coarse
// `"call MCP tool: <server>:<tool>"` label so one approve-for-session
// click unlocks every future call to the same server+tool combo. The
// server name is preserved to prevent cross-server privilege escalation.
if (toolName.startsWith('mcp__')) {
const rest = toolName.slice('mcp__'.length);
const sep = rest.indexOf('__');
if (sep >= 0) {
const serverName = rest.slice(0, sep);
const innerTool = rest.slice(sep + 2);
if (innerTool.length > 0) {
return `call MCP tool: ${serverName}:${innerTool}`;
}
}
}
return `call ${toolName}`;
}
/**
* Inverse mapping from an approve_for_session action label to the
* permission-rule pattern that should gate future same-action calls.
*
* When no entry matches, fall back to the concrete tool name. A `null`
* table entry means the action should be cached by action label only,
* without creating a broad PermissionRule.
*/
export function actionToRulePattern(
action: string,
fallbackToolName: string,
): string | undefined {
const mapped = ACTION_TO_PATTERN[action];
if (mapped !== undefined) return mapped ?? undefined;
return fallbackToolName;
}

View file

@ -1,59 +0,0 @@
import { isDefaultAutoAllowTool } from '../../tools/policies/default-permissions';
import { matchesRule } from './matches-rule';
import type { PermissionPathMatchOptions } from './path-glob-match';
import type { PermissionMode, PermissionRule, PermissionRuleDecision } from './types';
export interface CheckRulesResult {
readonly decision: PermissionRuleDecision;
/** Rule that produced `decision`. `undefined` for mode/default auto-allow. */
readonly matchedRule?: PermissionRule | undefined;
}
export function checkMatchingRules(
rules: readonly PermissionRule[],
toolName: string,
toolInput: unknown,
mode: PermissionMode,
pathOptions?: PermissionPathMatchOptions,
): CheckRulesResult | undefined {
// Priority 1: deny wins in every mode.
for (const rule of rules) {
if (rule.decision === 'deny' && matchesRule(rule, toolName, toolInput, pathOptions)) {
return { decision: 'deny', matchedRule: rule };
}
}
const askRule = firstMatchingRule(rules, 'ask', toolName, toolInput, pathOptions);
const allowRule = firstMatchingRule(rules, 'allow', toolName, toolInput, pathOptions);
if (askRule === undefined && allowRule === undefined) return undefined;
if (isDefaultAutoAllowTool(toolName)) {
return { decision: 'allow' };
}
// Mode overlay: yolo treats everything non-deny as allow.
if (mode === 'yolo' || mode === 'auto') {
return { decision: 'allow' };
}
// Priority 2: ask before allow so unresolved ambiguity defers to the user.
if (askRule !== undefined) return { decision: 'ask', matchedRule: askRule };
// Priority 3: explicit allow.
return { decision: 'allow', matchedRule: allowRule };
}
function firstMatchingRule(
rules: readonly PermissionRule[],
decision: PermissionRuleDecision,
toolName: string,
toolInput: unknown,
pathOptions?: PermissionPathMatchOptions,
): PermissionRule | undefined {
for (const rule of rules) {
if (rule.decision === decision && matchesRule(rule, toolName, toolInput, pathOptions)) {
return rule;
}
}
return undefined;
}

View file

@ -1,35 +1,35 @@
import type { Agent } from '..';
import type { PrepareToolExecutionResult, ToolExecutionHookContext } from '../../loop';
import type { TelemetryPropertyValue } from '../../telemetry';
import { isDefaultAutoAllowTool } from '../../tools/policies/default-permissions';
import type { ToolInputDisplay } from '../../tools/display';
import { actionToRulePattern, describeApprovalAction } from './action-label';
import { checkMatchingRules, type CheckRulesResult } from './check-rules';
import type { PermissionPathMatchOptions } from './path-glob-match';
import { createBuiltinPermissionPolicies } from './policies';
import type { PermissionPolicy, PermissionPolicyResult } from './policy';
import type { PrepareToolExecutionResult } from '../../loop';
import { createPermissionDecisionPolicies } from './policies';
import type {
ApprovalResponse,
PermissionApprovalResultRecord,
PermissionData,
PermissionMode,
PermissionRule,
PermissionPolicy,
PermissionPolicyContext,
PermissionPolicyResolution,
PermissionPolicyResult,
PermissionRule
} from './types';
export * from './policy';
export * from './types';
type ApprovalTelemetryMode = 'manual' | 'yolo' | 'afk' | 'auto_session' | 'cancelled';
export interface PermissionManagerOptions {
readonly initialRules?: readonly PermissionRule[] | undefined;
readonly policies?: readonly PermissionPolicy[] | undefined;
readonly parent?: PermissionManager | undefined;
readonly initialRules?: readonly PermissionRule[];
readonly parent?: PermissionManager;
}
interface PolicyEvaluation {
readonly policyName: string;
readonly result: PermissionPolicyResult;
}
export class PermissionManager {
rules: PermissionRule[] = [];
private modeOverride: PermissionMode | undefined;
private readonly parent: PermissionManager | undefined;
private readonly sessionApprovedActions = new Set<string>();
private readonly localSessionApprovalRulePatterns = new Set<string>();
private readonly policies: readonly PermissionPolicy[];
constructor(
@ -38,7 +38,7 @@ export class PermissionManager {
) {
this.rules = [...(options.initialRules ?? [])];
this.parent = options.parent;
this.policies = options.policies ?? createBuiltinPermissionPolicies();
this.policies = createPermissionDecisionPolicies(this.agent);
}
get mode(): PermissionMode {
@ -52,7 +52,7 @@ export class PermissionManager {
data(): PermissionData {
return {
mode: this.mode,
rules: this.effectiveRules(),
rules: this.effectiveRules,
};
}
@ -81,216 +81,166 @@ export class PermissionManager {
if (record.result.decision !== 'approved' || record.result.scope !== 'session') {
return;
}
if (this.sessionApprovedActions.has(record.action)) return;
const pattern = actionToRulePattern(record.action, record.toolName);
this.sessionApprovedActions.add(record.action);
const pattern = record.sessionApprovalRule;
if (pattern === undefined) return;
this.localSessionApprovalRulePatterns.add(pattern);
}
const rule: PermissionRule = {
decision: 'allow',
scope: 'session-runtime',
pattern,
reason: `approve_for_session: ${record.action}`,
};
if (!this.hasRule(rule)) {
this.rules.push(rule);
}
get sessionApprovalRulePatterns(): readonly string[] {
return [
...this.localSessionApprovalRulePatterns,
...(this.parent?.sessionApprovalRulePatterns ?? []),
];
}
async beforeToolCall(
context: ToolExecutionHookContext,
context: PermissionPolicyContext,
): Promise<PrepareToolExecutionResult | undefined> {
const name = context.toolCall.name;
const args = context.args;
const evaluation = await this.evaluatePolicies(context);
if (evaluation === undefined) return undefined;
const mode = this.mode;
const { decision, matchedRule } = this.checkPermission(name, args, mode);
if (decision === 'deny') {
return {
block: true,
reason: this.formatMessage(name, matchedRule?.reason),
};
}
const policyResult = await this.evaluatePolicies(context, matchedRule);
if (policyResult !== undefined) {
return this.permissionPolicyResultToPrepare(policyResult, context);
}
if (mode === 'auto') {
if (this.wouldAskInManualMode(name, args)) {
this.trackToolApproved(name, 'afk');
}
return undefined;
}
if (mode === 'yolo') {
if (this.wouldAskInManualMode(name, args)) {
this.trackToolApproved(name, 'yolo');
}
return undefined;
}
if (decision === 'allow') {
if (matchedRule?.scope === 'session-runtime') {
this.trackToolApproved(name, 'auto_session', 'session');
}
return undefined;
}
// decision === 'ask' → bounce through ApprovalRuntime.
return this.requestToolApproval(context);
this.agent.telemetry.track('permission_policy_decision', {
policy_name: evaluation.policyName,
tool_name: context.toolCall.name,
permission_mode: this.mode,
decision: evaluation.result.kind,
...evaluation.result.reason,
});
return this.permissionPolicyResolutionToPrepare(
evaluation.result,
context,
evaluation.policyName,
);
}
private async requestToolApproval(
context: ToolExecutionHookContext,
options: {
readonly action?: string | undefined;
readonly display?: ToolInputDisplay | undefined;
} = {},
context: PermissionPolicyContext,
result: Extract<PermissionPolicyResult, { kind: 'ask' }>,
policyName: string | undefined,
): Promise<PrepareToolExecutionResult | undefined> {
const { signal } = context;
const id = context.toolCall.id;
const name = context.toolCall.name;
const args = context.args;
const display =
options.display ?? ({
context.execution.display ?? {
kind: 'generic',
summary: `Approve ${name}`,
detail: args,
} satisfies ToolInputDisplay);
const action = options.action ?? describeApprovalAction(name, args, display);
if (this.sessionApprovedActions.has(action)) {
this.trackToolApproved(name, 'auto_session', 'session');
return undefined;
summary: context.execution.description ?? `Approve ${name}`,
detail: context.args,
};
const action = context.execution.description ?? `Call ${name}`;
const startedAt = Date.now();
let response: ApprovalResponse;
try {
response = await this.agent.rpc.requestApproval(
{
turnId: Number(context.turnId),
toolCallId: id,
toolName: name,
action,
display,
},
{ signal },
);
} catch (error) {
this.agent.telemetry.track('permission_approval_result', {
policy_name: policyName ?? null,
tool_name: name,
permission_mode: this.mode,
result: 'error',
approval_surface: display.kind,
duration_ms: Date.now() - startedAt,
session_cache_written: false,
has_feedback: false,
});
const resolved = result.resolveError?.(error);
return resolved === undefined
? Promise.reject(error)
: this.permissionPolicyResolutionToPrepare(resolved, context, policyName);
}
const result = await this.agent.rpc.requestApproval(
{
turnId: Number(context.turnId),
toolCallId: id,
toolName: name,
action,
display,
},
{ signal },
);
const sessionApprovalRule =
response.decision === 'approved' && response.scope === 'session'
? context.execution.approvalRule
: undefined;
this.recordApprovalResult({
turnId: Number(context.turnId),
toolCallId: id,
toolName: name,
action,
result,
sessionApprovalRule,
result: response,
});
this.agent.telemetry.track('permission_approval_result', {
policy_name: policyName ?? null,
tool_name: name,
permission_mode: this.mode,
result:
response.decision === 'approved' && response.scope === 'session'
? 'approved_for_session'
: response.decision,
approval_surface: display.kind,
duration_ms: Date.now() - startedAt,
session_cache_written: sessionApprovalRule !== undefined,
has_feedback: response.feedback !== undefined && response.feedback.length > 0,
});
if (result.decision === 'approved') {
this.trackToolApproved(
name,
approvalTelemetryMode(this.mode),
result.scope === 'session' ? 'session' : 'once',
);
const resolved = result.resolveApproval?.(response);
if (resolved !== undefined) {
return this.permissionPolicyResolutionToPrepare(resolved, context, policyName);
}
if (response.decision === 'approved') {
return undefined;
}
this.agent.telemetry.track('tool_rejected', {
tool_name: name,
approval_mode:
result.decision === 'cancelled' ? 'cancelled' : approvalTelemetryMode(this.mode),
decision: result.decision,
has_feedback: result.feedback !== undefined && result.feedback.length > 0,
});
return {
block: true,
reason: this.formatApprovalRejectionMessage(name, result),
reason: this.formatApprovalRejectionMessage(name, response),
};
}
private async evaluatePolicies(
context: ToolExecutionHookContext,
matchedRule: PermissionRule | undefined,
): Promise<PermissionPolicyResult | undefined> {
context: PermissionPolicyContext,
): Promise<PolicyEvaluation | undefined> {
for (const policy of this.policies) {
const result = await policy.evaluate({
agent: this.agent,
mode: this.mode,
toolCallContext: context,
matchedRule,
recordApprovalResult: (record) => {
this.recordApprovalResult(record);
},
});
if (result !== undefined) return result;
const result = await policy.evaluate(context);
if (result !== undefined) {
return { policyName: policy.name, result };
}
}
return undefined;
}
private checkPermission(
toolName: string,
toolInput: unknown,
mode: PermissionMode = this.mode,
): CheckRulesResult {
const matched = this.checkMatchingPermissionRules(toolName, toolInput, mode);
if (matched !== undefined) return matched;
if (isDefaultAutoAllowTool(toolName)) return { decision: 'allow' };
if (mode === 'yolo' || mode === 'auto') return { decision: 'allow' };
return { decision: 'ask' };
private get effectiveRules(): PermissionRule[] {
return [...this.rules, ...(this.parent?.effectiveRules ?? [])];
}
private checkMatchingPermissionRules(
toolName: string,
toolInput: unknown,
mode: PermissionMode,
): CheckRulesResult | undefined {
return (
checkMatchingRules(this.rules, toolName, toolInput, mode, this.pathMatchOptions()) ??
this.parent?.checkMatchingPermissionRules(toolName, toolInput, mode)
);
}
private effectiveRules(): PermissionRule[] {
return [...this.rules, ...(this.parent?.effectiveRules() ?? [])];
}
private wouldAskInManualMode(toolName: string, toolInput: unknown): boolean {
return this.checkPermission(toolName, toolInput, 'manual').decision === 'ask';
}
private permissionPolicyResultToPrepare(
result: PermissionPolicyResult,
context: ToolExecutionHookContext,
private permissionPolicyResolutionToPrepare(
result: PermissionPolicyResolution,
context: PermissionPolicyContext,
policyName?: string,
): Promise<PrepareToolExecutionResult | undefined> | PrepareToolExecutionResult | undefined {
switch (result.kind) {
case 'allow':
case 'approve':
return result.executionMetadata === undefined
? undefined
: { executionMetadata: result.executionMetadata };
case 'deny':
return {
block: true,
reason: result.message ?? this.formatPolicyDenyMessage(context.toolCall.name),
};
case 'ask':
return this.requestToolApproval(context, result);
case 'result':
return result.result;
return this.requestToolApproval(context, result, policyName);
case 'result': {
const { kind: _kind, ...prepareResult } = result;
return prepareResult;
}
}
}
private hasRule(target: PermissionRule): boolean {
return this.rules.some((rule) => {
return (
rule.decision === target.decision &&
rule.scope === target.scope &&
rule.pattern === target.pattern &&
rule.reason === target.reason
);
});
}
protected formatMessage(toolName: string, reason?: string): string {
const suffix = reason !== undefined && reason.length > 0 ? ` Reason: ${reason}` : '';
if (this.agent.type === 'sub') {
return `Tool "${toolName}" was denied.${suffix} Try a different approach — don't retry the same call, don't attempt to bypass the restriction.`;
}
return `Tool "${toolName}" was denied by permission rule.${suffix}`;
}
protected formatApprovalRejectionMessage(
toolName: string,
result: { decision: 'approved' | 'rejected' | 'cancelled'; feedback?: string },
@ -309,32 +259,11 @@ export class PermissionManager {
return `${prefix}${suffix}`;
}
private pathMatchOptions(): PermissionPathMatchOptions {
return {
cwd: this.agent.config.cwd,
pathClass: this.agent.runtime.kaos.pathClass(),
homeDir: this.agent.runtime.kaos.gethome(),
};
}
private trackToolApproved(
toolName: string,
approvalMode: Exclude<ApprovalTelemetryMode, 'cancelled'>,
scope?: 'once' | 'session',
): void {
const properties: Record<string, TelemetryPropertyValue> = {
tool_name: toolName,
approval_mode: approvalMode,
};
if (scope !== undefined) {
properties['scope'] = scope;
private formatPolicyDenyMessage(toolName: string): string {
const prefix = `Tool "${toolName}" was denied by permission policy.`;
if (this.agent.type === 'sub') {
return `${prefix} Try a different approach — don't retry the same call, don't attempt to bypass the restriction.`;
}
this.agent.telemetry.track('tool_approved', properties);
return prefix;
}
}
function approvalTelemetryMode(
mode: PermissionMode,
): Extract<ApprovalTelemetryMode, 'manual' | 'yolo' | 'afk'> {
return mode === 'auto' ? 'afk' : mode;
}

View file

@ -1,125 +1,98 @@
/**
* matchesRule pure function that decides whether a PermissionRule
* applies to a given tool call.
*
* Contract:
* - No side effects, no `this`, no IO, no exceptions.
* - Deterministic: same `(rule, toolName, args)` same result.
* - Returns boolean only; decision semantics (deny/ask/allow) are a
* caller concern (see `check-rules.ts`).
*/
import picomatch from 'picomatch';
import { parsePattern } from './parse-pattern';
import { globMatch, pathGlobMatch, type PermissionPathMatchOptions } from './path-glob-match';
import type { RunnableToolExecution } from '../../loop/types';
import type { PermissionRule } from './types';
type ArgFieldKind = 'generic' | 'path';
/**
* DSL parser for PermissionRule `pattern` strings.
*
* Grammar:
* pattern := toolName ( "(" argPattern ")" )?
* toolName := identifier characters (e.g. `Bash`, `mcp__github__*`)
* argPattern := any string interpreted only by a tool-provided matcher
*
* Examples:
* "Write" -> { toolName: "Write" }
* "Read(/etc/**)" -> { toolName: "Read", argPattern: "/etc/**" }
* "Bash(!rm *)" -> { toolName: "Bash", argPattern: "!rm *" }
* "mcp__github__*" -> { toolName: "mcp__github__*" }
*/
export interface ParsedPattern {
readonly toolName: string;
readonly argPattern?: string;
}
interface ArgField {
readonly value: string;
readonly kind: ArgFieldKind;
export interface PermissionRuleMatchExecution {
readonly matchesRule?: RunnableToolExecution['matchesRule'];
}
export type PermissionRuleMatchStrategy = 'tool_name_only' | 'matches_rule';
export interface PermissionRuleMatch {
readonly rule: PermissionRule;
readonly strategy: PermissionRuleMatchStrategy;
readonly hasRuleArgs: boolean;
}
export interface PermissionRuleMatchInput {
readonly rule: PermissionRule;
readonly toolName: string;
readonly execution: PermissionRuleMatchExecution;
}
/**
* Tool-specific argument field convention. When a rule uses an arg
* pattern (`Read(./src/**)`), we extract the listed field from the tool
* call args and match the glob against its value.
*
* Unknown tools fall back to `undefined`, which means "arg pattern
* cannot match" rules with an arg pattern on an unknown tool will
* never fire. Rules without an arg pattern (`UnknownTool`) still match
* on name alone.
* Parse a DSL pattern. Throws on malformed input (missing closing paren,
* empty tool name). The parser is the single source of truth for DSL syntax.
*/
function extractArgField(toolName: string, args: unknown): ArgField | undefined {
if (args === null || typeof args !== 'object') return undefined;
const rec = args as Record<string, unknown>;
switch (toolName) {
case 'Bash':
case 'Shell':
case 'Background':
return typeof rec['command'] === 'string'
? { value: rec['command'], kind: 'generic' }
: undefined;
case 'Read':
case 'Write':
case 'Edit':
case 'ReadMediaFile':
return typeof rec['path'] === 'string' ? { value: rec['path'], kind: 'path' } : undefined;
case 'Grep':
case 'Glob':
return typeof rec['pattern'] === 'string'
? { value: rec['pattern'], kind: 'generic' }
: undefined;
case 'Task':
case 'Agent':
return typeof rec['subagent_type'] === 'string'
? { value: rec['subagent_type'], kind: 'generic' }
: undefined;
default:
return undefined;
export function parsePattern(pattern: string): ParsedPattern {
const trimmed = pattern.trim();
if (trimmed.length === 0) {
throw new Error('permission pattern: empty string');
}
const openIdx = trimmed.indexOf('(');
if (openIdx === -1) {
return { toolName: trimmed };
}
if (!trimmed.endsWith(')')) {
throw new Error(`permission pattern: missing closing paren in "${pattern}"`);
}
const toolName = trimmed.slice(0, openIdx);
const argPattern = trimmed.slice(openIdx + 1, -1);
if (toolName.length === 0) {
throw new Error(`permission pattern: empty tool name in "${pattern}"`);
}
// `Tool()` parses to no arg pattern so it stays tool-name-only — tools without
// a `matchesRule` matcher (user/MCP/custom) would otherwise stop matching it.
if (argPattern.length === 0) {
return { toolName };
}
return { toolName, argPattern };
}
/**
* Decide whether a single rule matches a specific tool call.
*
* Algorithm:
* 1. Parse `rule.pattern` into `{toolName, argPattern?}`.
* 2. If parsed toolName is `*`, skip name check; otherwise compare with
* glob semantics so `mcp__github__*` matches `mcp__github__list`.
* 3. If the rule has no argPattern, name match rule fires.
* 4. Otherwise extract the tool-specific field value and match against
* the glob. Handle the leading `!` negation prefix by flipping the
* final boolean.
*/
export function matchesRule(
rule: PermissionRule,
toolName: string,
args: unknown,
pathOptions?: PermissionPathMatchOptions,
): boolean {
export function matchPermissionRule({
rule,
toolName,
execution,
}: PermissionRuleMatchInput): PermissionRuleMatch | undefined {
let parsed;
try {
parsed = parsePattern(rule.pattern);
} catch {
// Malformed patterns never match. The loader is responsible for
// surfacing DSL errors at load time; matcher stays total.
return false;
return undefined;
}
// 1. Tool-name match (support `*` wildcard + glob-style tool names)
const nameGlob = parsed.toolName;
if (nameGlob !== '*' && !picomatch.isMatch(toolName, nameGlob)) return false;
// 2. No arg pattern → name match is enough
if (parsed.argPattern === undefined) return true;
// 3. Arg pattern — resolve negation and glob-match the field
const rawPattern = parsed.argPattern;
const negated = rawPattern.startsWith('!');
const positivePattern = negated ? rawPattern.slice(1) : rawPattern;
const fieldValue = extractArgField(toolName, args);
if (fieldValue === undefined) {
// No extractable field → positive pattern cannot match; negation
// semantics here mean "the field is not in the disallowed set",
// which by missing-field convention we treat as a non-match to
// avoid accidentally firing deny rules on malformed args.
return false;
if (parsed.toolName !== '*' && !picomatch.isMatch(toolName, parsed.toolName)) {
return undefined;
}
// If the field is a path, use `pathGlobMatch` which handles normalization.
const hit =
fieldValue.kind === 'path'
? pathGlobMatch(fieldValue.value, positivePattern, {
pathOptions,
conservativeCaseFold: rule.decision === 'deny' && !negated,
})
: globMatch(fieldValue.value, positivePattern);
return negated ? !hit : hit;
if (parsed.argPattern === undefined) {
return { rule, strategy: 'tool_name_only', hasRuleArgs: false };
}
return execution.matchesRule?.(parsed.argPattern) === true
? { rule, strategy: 'matches_rule', hasRuleArgs: true }
: undefined;
}
export type { PermissionPathMatchOptions } from './path-glob-match';

View file

@ -1,50 +0,0 @@
/**
* DSL parser for PermissionRule `pattern` strings.
*
* Grammar:
* pattern := toolName ( "(" argPattern ")" )?
* toolName := identifier characters (e.g. `Bash`, `mcp__github__*`)
* argPattern := any string (may start with `!` for negation)
*
* Examples:
* "Write" { toolName: "Write" }
* "Read(/etc/**)" { toolName: "Read", argPattern: "/etc/**" }
* "Bash(!rm *)" { toolName: "Bash", argPattern: "!rm *" }
* "mcp__github__*" { toolName: "mcp__github__*" }
*/
export interface ParsedPattern {
readonly toolName: string;
readonly argPattern?: string | undefined;
}
/**
* Parse a DSL pattern. Throws on malformed input (missing closing paren,
* empty tool name). The parser is the single source of truth for DSL
* syntax and is exercised by table-driven tests.
*/
export function parsePattern(pattern: string): ParsedPattern {
const trimmed = pattern.trim();
if (trimmed.length === 0) {
throw new Error('permission pattern: empty string');
}
const openIdx = trimmed.indexOf('(');
if (openIdx === -1) {
return { toolName: trimmed };
}
if (!trimmed.endsWith(')')) {
throw new Error(`permission pattern: missing closing paren in "${pattern}"`);
}
const toolName = trimmed.slice(0, openIdx);
const argPattern = trimmed.slice(openIdx + 1, -1);
if (toolName.length === 0) {
throw new Error(`permission pattern: empty tool name in "${pattern}"`);
}
// Empty arg pattern (`Read()`) is treated as "toolName only" — it
// matches every call to that tool. This aligns with the intuition
// that writing `Read()` is an odd but non-fatal way of saying `Read`.
return { toolName, argPattern: argPattern.length > 0 ? argPattern : undefined };
}

View file

@ -1,17 +0,0 @@
import type { PermissionPolicy } from '../policy';
export const AskUserQuestionAutoPermissionPolicy: PermissionPolicy = {
name: 'auto.ask-user-question',
evaluate({ mode, toolCallContext }) {
if (mode !== 'auto') return undefined;
if (toolCallContext.toolCall.name !== 'AskUserQuestion') return undefined;
return {
kind: 'result',
result: {
block: true,
reason:
'AskUserQuestion is disabled while auto permission mode is active. Make a reasonable decision and continue without asking the user.',
},
};
},
};

View file

@ -0,0 +1,15 @@
import type { Agent } from '../..';
import type { PermissionPolicy, PermissionPolicyResult } from '../types';
export class AutoModeApprovePermissionPolicy implements PermissionPolicy {
readonly name = 'auto-mode-approve';
constructor(private readonly agent: Agent) {}
evaluate(): PermissionPolicyResult | undefined {
if (this.agent.permission.mode !== 'auto') return;
return {
kind: 'approve',
};
}
}

View file

@ -0,0 +1,18 @@
import type { Agent } from '../..';
import type { PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult } from '../types';
export class AutoModeAskUserQuestionDenyPermissionPolicy implements PermissionPolicy {
readonly name = 'auto-mode-ask-user-question-deny';
constructor(private readonly agent: Agent) {}
evaluate(context: PermissionPolicyContext): PermissionPolicyResult | undefined {
if (this.agent.permission.mode !== 'auto') return;
if (context.toolCall.name !== 'AskUserQuestion') return;
return {
kind: 'deny',
message:
'AskUserQuestion is disabled while auto permission mode is active. Make a reasonable decision and continue without asking the user.',
};
}
}

View file

@ -1,127 +0,0 @@
import { join, relative, sep } from 'pathe';
import type { Kaos } from '@moonshot-ai/kaos';
import {
DEFAULT_WORKSPACE_ACCESS_POLICY,
isWithinDirectory,
resolvePathAccess,
} from '../../../tools/policies/path-access';
import { isSensitiveFile } from '../../../tools/policies/sensitive';
import {
findGitWorkTreeMarker,
type GitWorkTreeMarker,
} from '../../../tools/support/git-worktree';
import type { PermissionPolicy } from '../policy';
const AUTO_REASON = 'default_git_cwd_write';
const S_IFMT = 0o170000;
const S_IFLNK = 0o120000;
export function createDefaultGitCwdWritePolicy(): PermissionPolicy {
// Cache positive marker lookups only. A session that starts in a non-git
// directory and later `git init`s should pick up the new work tree on the
// next call; negative results pay one extra stat per call, which is
// acceptable.
const cache = new Map<string, GitWorkTreeMarker>();
return {
name: 'default.git-cwd-write',
async evaluate({ agent, mode, matchedRule, toolCallContext }) {
if (mode !== 'manual') return undefined;
if (matchedRule !== undefined) return undefined;
const toolName = toolCallContext.toolCall.name;
if (toolName !== 'Write' && toolName !== 'Edit') return undefined;
const kaos = agent.runtime.kaos;
const pathClass = kaos.pathClass();
if (pathClass !== 'posix') return undefined;
const cwd = agent.config.cwd;
if (cwd.length === 0) return undefined;
const path = readStringField(toolCallContext.args, 'path');
if (path === undefined) return undefined;
let access;
try {
access = resolvePathAccess(
path,
cwd,
{ workspaceDir: cwd, additionalDirs: [] },
{
operation: 'write',
pathClass,
homeDir: kaos.gethome(),
policy: DEFAULT_WORKSPACE_ACCESS_POLICY,
},
);
} catch {
return undefined;
}
if (access.outsideWorkspace) return undefined;
const marker = cache.get(cwd) ?? (await findGitWorkTreeMarker(kaos, cwd));
if (marker === null) return undefined;
cache.set(cwd, marker);
if (isGitControlPath(access.path, cwd, marker)) return undefined;
if (isSensitiveFile(access.path.toLowerCase(), 'posix')) return undefined;
if (await hasSymlinkInPath(kaos, cwd, access.path)) return undefined;
agent.telemetry.track('tool_approved', {
tool_name: toolName,
approval_mode: 'manual',
auto_reason: AUTO_REASON,
});
return { kind: 'allow' };
},
};
}
function readStringField(args: unknown, key: string): string | undefined {
if (args === null || typeof args !== 'object') return undefined;
const value = (args as Record<string, unknown>)[key];
return typeof value === 'string' ? value : undefined;
}
function isGitControlPath(targetPath: string, cwd: string, marker: GitWorkTreeMarker): boolean {
const foldedTarget = targetPath.toLowerCase();
return (
relative(cwd.toLowerCase(), foldedTarget).split(sep).includes('.git') ||
isWithinDirectory(foldedTarget, marker.dotGitPath.toLowerCase(), 'posix') ||
isWithinDirectory(foldedTarget, marker.controlDirPath.toLowerCase(), 'posix')
);
}
async function hasSymlinkInPath(kaos: Kaos, cwd: string, targetPath: string): Promise<boolean> {
const relPath = relative(cwd, targetPath);
const parts = [cwd];
let current = cwd;
for (const part of relPath.split(sep)) {
if (part.length === 0 || part === '.') continue;
current = join(current, part);
parts.push(current);
}
for (let index = 0; index < parts.length; index += 1) {
try {
const stat = await kaos.stat(parts[index]!, { followSymlinks: false });
if ((stat.stMode & S_IFMT) === S_IFLNK) return true;
} catch (error) {
return !(index === parts.length - 1 && isFileNotFoundError(error));
}
}
return false;
}
function isFileNotFoundError(error: unknown): boolean {
if (error === null || typeof error !== 'object') return false;
if ((error as { name?: unknown }).name === 'KaosFileNotFoundError') return true;
const code = (error as { code?: unknown }).code;
if (code === 'ENOENT' || code === 'ENOTDIR' || code === 2) return true;
const message = error instanceof Error ? error.message : '';
return message.includes('ENOENT') || message.includes('ENOTDIR');
}

View file

@ -0,0 +1,28 @@
import type { PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult } from '../types';
const DEFAULT_APPROVE_TOOLS = new Set([
'Read',
'Grep',
'Glob',
'ReadMediaFile',
'SetTodoList',
'TodoList',
'TaskList',
'TaskOutput',
'WebSearch',
'FetchURL',
'Agent',
'AskUserQuestion',
'Skill',
]);
export class DefaultToolApprovePermissionPolicy implements PermissionPolicy {
readonly name = 'default-tool-approve';
evaluate(context: PermissionPolicyContext): PermissionPolicyResult | undefined {
if (!DEFAULT_APPROVE_TOOLS.has(context.toolCall.name)) return;
return {
kind: 'approve',
};
}
}

View file

@ -0,0 +1,172 @@
import type { Agent } from '../..';
import type { ApprovalResponse, PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult } from '../types';
interface ExitPlanModeOption {
readonly label: string;
readonly description: string;
}
interface PlanReviewDisplay {
readonly plan: string;
readonly path?: string | undefined;
readonly options?: readonly ExitPlanModeOption[] | undefined;
}
export class ExitPlanModeReviewAskPermissionPolicy implements PermissionPolicy {
readonly name = 'exit-plan-mode-review-ask';
constructor(private readonly agent: Agent) {}
evaluate(context: PermissionPolicyContext): PermissionPolicyResult | undefined {
if (context.toolCall.name !== 'ExitPlanMode') return;
if (this.agent.permission.mode === 'auto') return;
if (!this.agent.planMode.isActive) return;
const display = context.execution.display;
if (display?.kind !== 'plan_review') return;
if (display.plan.trim().length === 0) return;
this.agent.telemetry.track('plan_submitted', {
has_options: display.options !== undefined && display.options.length >= 2,
});
return {
kind: 'ask',
reason: {
has_options: display.options !== undefined,
},
resolveApproval: (result) =>
this.exitPlanModeApprovalResult(result, {
plan: display.plan,
path: display.path,
options: display.options,
}),
};
}
private exitPlanModeApprovalResult(result: ApprovalResponse, display: PlanReviewDisplay) {
if (result.decision !== 'approved') {
return this.rejectedExitPlanModeApprovalResult(result);
}
const selected = selectedExitPlanModeOption(display.options, result.selectedLabel);
const failed = this.exitPlanMode();
if (failed !== undefined) {
return { kind: 'result' as const, syntheticResult: failed };
}
if (result.selectedLabel !== undefined && result.selectedLabel.length > 0) {
this.agent.telemetry.track('plan_resolved', {
outcome: 'approved',
chosen_option: result.selectedLabel,
});
} else {
this.agent.telemetry.track('plan_resolved', { outcome: 'approved' });
}
const optionPrefix =
selected === undefined
? ''
: `Selected approach: ${selected.label}\nExecute ONLY the selected approach. Do not execute any unselected alternatives.\n\n`;
const savedTo = display.path !== undefined ? `Plan saved to: ${display.path}\n\n` : '';
const formattedPlan = `Plan mode deactivated. All tools are now available.\n${savedTo}## Approved Plan:\n${display.plan}`;
return {
kind: 'result' as const,
syntheticResult: {
isError: false,
output: `Exited plan mode. ${optionPrefix}${formattedPlan}`,
},
};
}
private rejectedExitPlanModeApprovalResult(result: ApprovalResponse) {
this.trackRejectedPlanResolution(result);
if (result.decision === 'cancelled') {
return {
kind: 'result' as const,
syntheticResult: {
isError: false,
output: 'Plan approval dismissed. Plan mode remains active.',
},
};
}
if (result.selectedLabel === 'Reject and Exit') {
const failed = this.exitPlanMode();
return {
kind: 'result' as const,
syntheticResult:
failed ?? {
isError: true,
stopTurn: true,
output: 'Plan rejected by user. Plan mode deactivated.',
},
};
}
const feedback = result.feedback ?? '';
if (result.selectedLabel === 'Revise' || feedback.length > 0) {
return {
kind: 'result' as const,
syntheticResult: {
isError: false,
output:
feedback.length > 0
? `User rejected the plan. Feedback:\n\n${feedback}`
: 'User requested revisions. Plan mode remains active.',
},
};
}
return {
kind: 'result' as const,
syntheticResult: {
isError: true,
stopTurn: true,
output: 'Plan rejected by user. Plan mode remains active.',
},
};
}
private exitPlanMode(): { isError: true; output: string } | undefined {
try {
this.agent.planMode.exit();
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to exit plan mode.';
return {
isError: true,
output: `Failed to exit plan mode: ${message}`,
};
}
}
private trackRejectedPlanResolution(result: ApprovalResponse): void {
if (result.decision === 'cancelled') {
this.agent.telemetry.track('plan_resolved', { outcome: 'dismissed' });
return;
}
if (result.selectedLabel === 'Reject and Exit') {
this.agent.telemetry.track('plan_resolved', { outcome: 'rejected_and_exited' });
return;
}
const feedback = result.feedback ?? '';
if (result.selectedLabel === 'Revise' || feedback.length > 0) {
this.agent.telemetry.track('plan_resolved', {
outcome: 'revise',
has_feedback: feedback.length > 0,
});
return;
}
this.agent.telemetry.track('plan_resolved', { outcome: 'rejected' });
}
}
function selectedExitPlanModeOption(
options: readonly ExitPlanModeOption[] | undefined,
label: string | undefined,
): ExitPlanModeOption | undefined {
if (options === undefined || label === undefined) return;
return options.find((option) => option.label === label);
}

View file

@ -0,0 +1,11 @@
import type { PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult } from '../types';
export class FallbackAskPermissionPolicy implements PermissionPolicy {
readonly name = 'fallback-ask';
evaluate(_context: PermissionPolicyContext): PermissionPolicyResult {
return {
kind: 'ask',
};
}
}

View file

@ -0,0 +1,135 @@
import * as posixPath from 'node:path/posix';
import * as win32Path from 'node:path/win32';
import type { Agent } from '../..';
import type { ToolFileAccess } from '../../../loop/tool-access';
import { isWithinDirectory, type PathClass } from '../../../tools/policies/path-access';
import { isSensitiveFile } from '../../../tools/policies/sensitive';
import {
findGitWorkTreeMarker,
type GitWorkTreeMarker,
} from '../../../tools/support/git-worktree';
import type { PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult } from '../types';
export class SensitiveFileAccessAskPermissionPolicy implements PermissionPolicy {
readonly name = 'sensitive-file-access-ask';
constructor(private readonly agent: Agent) {}
evaluate(context: PermissionPolicyContext): PermissionPolicyResult | undefined {
const access = fileAccesses(context).find((fileAccess) =>
isSensitiveFile(fileAccess.path),
);
if (access === undefined) return;
return {
kind: 'ask',
reason: fileAccessReason(access, { sensitive_path: true }),
};
}
}
export class GitControlPathAccessAskPermissionPolicy implements PermissionPolicy {
readonly name = 'git-control-path-access-ask';
constructor(private readonly agent: Agent) {}
async evaluate(context: PermissionPolicyContext): Promise<PermissionPolicyResult | undefined> {
const cwd = this.agent.config.cwd;
if (cwd.length === 0) return;
const pathClass = this.agent.runtime.kaos.pathClass();
const accesses = fileAccesses(context);
if (accesses.length === 0) return;
const directGitAccess = accesses.find((fileAccess) => {
return hasGitPathComponent(fileAccess.path, cwd, pathClass);
});
if (directGitAccess !== undefined) {
return {
kind: 'ask',
reason: fileAccessReason(directGitAccess, { git_control_path: true }),
};
}
const marker = await findGitWorkTreeMarker(this.agent.runtime.kaos, cwd);
if (marker === null) return;
const access = accesses.find((fileAccess) => {
return isGitControlPath(fileAccess.path, marker, pathClass);
});
if (access === undefined) return;
return {
kind: 'ask',
reason: fileAccessReason(access, { git_control_path: true }),
};
}
}
export class CwdOutsideFileWriteAskPermissionPolicy implements PermissionPolicy {
readonly name = 'cwd-outside-file-write-ask';
constructor(private readonly agent: Agent) {}
evaluate(context: PermissionPolicyContext): PermissionPolicyResult | undefined {
const cwd = this.agent.config.cwd;
if (cwd.length === 0) return;
const pathClass = this.agent.runtime.kaos.pathClass();
const access = writeFileAccesses(context).find((fileAccess) => {
return !isWithinDirectory(fileAccess.path, cwd, pathClass);
});
if (access === undefined) return;
return {
kind: 'ask',
reason: fileAccessReason(access, { cwd_outside: true }),
};
}
}
function fileAccesses(context: PermissionPolicyContext): ToolFileAccess[] {
return (
context.execution.accesses?.filter((access): access is ToolFileAccess => access.kind === 'file') ??
[]
);
}
export function writeFileAccesses(context: PermissionPolicyContext): ToolFileAccess[] {
return fileAccesses(context).filter(
(access) => access.operation === 'write' || access.operation === 'readwrite',
);
}
function fileAccessReason(access: ToolFileAccess, extra: Record<string, boolean>) {
return {
file_access_operation: access.operation,
recursive: access.recursive === true,
...extra,
};
}
function hasGitPathComponent(
targetPath: string,
cwd: string,
pathClass: PathClass,
): boolean {
return relativePathParts(targetPath, cwd, pathClass).some((part) => part.toLowerCase() === '.git');
}
function isGitControlPath(
targetPath: string,
marker: GitWorkTreeMarker,
pathClass: PathClass,
): boolean {
return (
isWithinDirectory(targetPath, marker.dotGitPath, pathClass) ||
isWithinDirectory(targetPath, marker.controlDirPath, pathClass)
);
}
function relativePathParts(targetPath: string, cwd: string, pathClass: PathClass): string[] {
return pathMod(pathClass)
.relative(cwd, targetPath)
.split(/[\\/]+/)
.filter((part) => part.length > 0);
}
function pathMod(pathClass: PathClass): typeof posixPath {
return pathClass === 'win32' ? win32Path : posixPath;
}

View file

@ -0,0 +1,33 @@
import type { Agent } from '../..';
import { isWithinDirectory } from '../../../tools/policies/path-access';
import { findGitWorkTreeMarker } from '../../../tools/support/git-worktree';
import type { PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult } from '../types';
import { writeFileAccesses } from './file-access-ask';
export class GitCwdWriteApprovePermissionPolicy implements PermissionPolicy {
readonly name = 'git-cwd-write-approve';
constructor(private readonly agent: Agent) {}
async evaluate(context: PermissionPolicyContext): Promise<PermissionPolicyResult | undefined> {
const toolName = context.toolCall.name;
if (toolName !== 'Write' && toolName !== 'Edit') return;
if (this.agent.runtime.kaos.pathClass() !== 'posix') return;
const cwd = this.agent.config.cwd;
if (cwd.length === 0) return;
const writeAccesses = writeFileAccesses(context);
if (writeAccesses.length === 0) return;
if (!writeAccesses.every((access) => isWithinDirectory(access.path, cwd, 'posix'))) {
return;
}
const marker = await findGitWorkTreeMarker(this.agent.runtime.kaos, cwd);
if (marker === null) return;
return {
kind: 'approve',
};
}
}

View file

@ -1,19 +1,63 @@
import type { PermissionPolicy } from '../policy';
import { AskUserQuestionAutoPermissionPolicy } from './ask-user-question';
import { createDefaultGitCwdWritePolicy } from './default-git-cwd-write';
import { createPlanPermissionPolicies } from './plan';
import { YoloOutsideWorkspacePermissionPolicy } from './yolo-workspace-access';
import type { Agent } from '../..';
import type { PermissionPolicy } from '../types';
import { AutoModeApprovePermissionPolicy } from './auto-mode-approve';
import { AutoModeAskUserQuestionDenyPermissionPolicy } from './auto-mode-ask-user-question-deny';
import { DefaultToolApprovePermissionPolicy } from './default-tool-approve';
import { ExitPlanModeReviewAskPermissionPolicy } from './exit-plan-mode-review-ask';
import { FallbackAskPermissionPolicy } from './fallback-ask';
import {
CwdOutsideFileWriteAskPermissionPolicy,
GitControlPathAccessAskPermissionPolicy,
SensitiveFileAccessAskPermissionPolicy,
} from './file-access-ask';
import { GitCwdWriteApprovePermissionPolicy } from './git-cwd-write-approve';
import { PlanModeGuardDenyPermissionPolicy } from './plan-mode-guard-deny';
import { PlanModeToolApprovePermissionPolicy } from './plan-mode-tool-approve';
import { PreToolCallHookPermissionPolicy } from './pre-tool-call-hook';
import { SessionApprovalHistoryPermissionPolicy } from './session-approval-history';
import {
UserConfiguredAllowPermissionPolicy,
UserConfiguredAskPermissionPolicy,
UserConfiguredDenyPermissionPolicy,
} from './user-configured-rules';
import { YoloModeApprovePermissionPolicy } from './yolo-mode-approve';
export function createBuiltinPermissionPolicies(): readonly PermissionPolicy[] {
/** Permission policies run in order; the first non-undefined result wins. */
export function createPermissionDecisionPolicies(agent: Agent): readonly PermissionPolicy[] {
return [
...createPlanPermissionPolicies(),
YoloOutsideWorkspacePermissionPolicy,
createDefaultGitCwdWritePolicy(),
AskUserQuestionAutoPermissionPolicy,
// PreToolUse hook returned a block → deny.
new PreToolCallHookPermissionPolicy(agent),
// auto mode + AskUserQuestion → deny.
new AutoModeAskUserQuestionDenyPermissionPolicy(agent),
// plan mode: Write/Edit outside the plan file, or TaskStop → deny.
new PlanModeGuardDenyPermissionPolicy(agent),
// User-configured deny rule matches → deny.
new UserConfiguredDenyPermissionPolicy(agent),
// auto mode → approve (any auto-mode block must be a deny rule above this).
new AutoModeApprovePermissionPolicy(agent),
// Approve-for-session memorized rule matches → approve. Runs before user-configured ask rules so an in-session grant beats a still-matching ask rule on later calls.
new SessionApprovalHistoryPermissionPolicy(agent),
// User-configured ask rule matches → ask.
new UserConfiguredAskPermissionPolicy(agent),
// User-configured allow rule matches → approve.
new UserConfiguredAllowPermissionPolicy(agent),
// ExitPlanMode with active plan_review + non-empty plan + non-auto → ask (tracks plan_submitted/plan_resolved itself). Runs before session history so a stale session approval can't bypass review of a new plan body.
new ExitPlanModeReviewAskPermissionPolicy(agent),
// EnterPlanMode, Write/Edit on the plan file, or ExitPlanMode with no actionable plan_review → approve.
new PlanModeToolApprovePermissionPolicy(agent),
// Access touches a sensitive file (.env, SSH key, credentials) → ask.
new SensitiveFileAccessAskPermissionPolicy(agent),
// Access touches .git or a git control-dir path → ask.
new GitControlPathAccessAskPermissionPolicy(agent),
// Write target is outside cwd → ask. Reads and searches outside cwd are allowed without prompting.
new CwdOutsideFileWriteAskPermissionPolicy(agent),
// yolo mode → approve.
new YoloModeApprovePermissionPolicy(agent),
// Tool is in the default-approve list (read-only / UI helpers) → approve.
new DefaultToolApprovePermissionPolicy(),
// Write/Edit on POSIX paths inside cwd inside a git work tree → approve.
new GitCwdWriteApprovePermissionPolicy(agent),
// Nothing matched → ask.
new FallbackAskPermissionPolicy(),
];
}
export { AskUserQuestionAutoPermissionPolicy } from './ask-user-question';
export { createDefaultGitCwdWritePolicy } from './default-git-cwd-write';
export { createPlanPermissionPolicies } from './plan';
export { YoloOutsideWorkspacePermissionPolicy } from './yolo-workspace-access';

View file

@ -0,0 +1,54 @@
import type { Agent } from '../..';
import type { PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult } from '../types';
import { writeFileAccesses } from './file-access-ask';
export class PlanModeGuardDenyPermissionPolicy implements PermissionPolicy {
readonly name = 'plan-mode-guard-deny';
constructor(private readonly agent: Agent) {}
evaluate(context: PermissionPolicyContext): PermissionPolicyResult | undefined {
if (!this.agent.planMode.isActive) return;
const toolName = context.toolCall.name;
if (toolName === 'Write' || toolName === 'Edit') {
const planFilePath = this.agent.planMode.planFilePath;
if (planFilePath === null) {
return {
kind: 'deny',
message: planModeWriteDeniedMessage(planFilePath),
};
}
if (writesOnlyPlanFile(context, planFilePath)) {
return;
}
return {
kind: 'deny',
message: planModeWriteDeniedMessage(planFilePath),
};
}
if (toolName !== 'TaskStop') return;
return {
kind: 'deny',
message:
'TaskStop is not available in plan mode. Call ExitPlanMode to exit plan mode before stopping a background task.',
};
}
}
function writesOnlyPlanFile(
context: PermissionPolicyContext,
planFilePath: string,
): boolean {
const writeAccesses = writeFileAccesses(context);
if (writeAccesses.length === 0) return false;
return writeAccesses.every((access) => access.path === planFilePath);
}
function planModeWriteDeniedMessage(planFilePath: string | null): string {
return (
`Plan mode is active. You may only write to the current plan file: ${planFilePath ?? '(no plan file selected yet)'}. ` +
'Call ExitPlanMode to exit plan mode before editing other files.'
);
}

View file

@ -0,0 +1,54 @@
import type { Agent } from '../..';
import type { PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult } from '../types';
import { writeFileAccesses } from './file-access-ask';
export class PlanModeToolApprovePermissionPolicy implements PermissionPolicy {
readonly name = 'plan-mode-tool-approve';
constructor(private readonly agent: Agent) {}
evaluate(context: PermissionPolicyContext): PermissionPolicyResult | undefined {
const toolName = context.toolCall.name;
if (toolName === 'EnterPlanMode') {
return {
kind: 'approve',
};
}
if (
(toolName === 'Write' || toolName === 'Edit') &&
this.agent.planMode.isActive &&
writesOnlyPlanFile(context, this.agent.planMode.planFilePath)
) {
return {
kind: 'approve',
};
}
if (toolName === 'ExitPlanMode') {
if (!this.agent.planMode.isActive) {
return {
kind: 'approve',
};
}
if (context.execution.display?.kind !== 'plan_review') {
return {
kind: 'approve',
};
}
if (context.execution.display.plan.trim().length > 0) return;
return {
kind: 'approve',
};
}
}
}
function writesOnlyPlanFile(
context: PermissionPolicyContext,
planFilePath: string | null,
): boolean {
if (planFilePath === null) return false;
const writeAccesses = writeFileAccesses(context);
return writeAccesses.every((access) => access.path === planFilePath);
}

View file

@ -1,324 +0,0 @@
import type { ExecutableToolResult } from '../../../loop';
import type { PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult } from '../policy';
import type { ApprovalResponse } from '../types';
interface ExitPlanModeOption {
readonly label: string;
readonly description: string;
}
interface ExitPlanModeExecutionMetadata {
readonly selectedOption?: ExitPlanModeOption | undefined;
readonly planTelemetrySubmitted: true;
readonly planTelemetryResolved: true;
}
export const EnterPlanModePermissionPolicy: PermissionPolicy = {
name: 'plan.enter-plan-mode',
evaluate({ toolCallContext }) {
if (toolCallContext.toolCall.name !== 'EnterPlanMode') return undefined;
return { kind: 'allow' };
},
};
export const ExitPlanModePermissionPolicy: PermissionPolicy = {
name: 'plan.exit-plan-mode',
async evaluate(context) {
if (context.toolCallContext.toolCall.name !== 'ExitPlanMode') return undefined;
if (context.mode === 'auto') return { kind: 'allow' };
const review = await resolveExitPlanModeReview(context);
if (review === null) return { kind: 'allow' };
const action = exitPlanModeAction(review.options);
context.agent.telemetry.track('plan_submitted', {
has_options: review.options !== undefined,
});
let result: ApprovalResponse;
try {
result = await context.agent.rpc.requestApproval(
{
turnId: Number(context.toolCallContext.turnId),
toolCallId: context.toolCallContext.toolCall.id,
toolName: 'ExitPlanMode',
action,
display: {
kind: 'plan_review',
plan: review.plan,
path: review.path,
options: review.options,
},
},
{ signal: context.toolCallContext.signal },
);
} catch (error) {
const message = error instanceof Error ? error.message : 'Plan approval failed.';
return {
kind: 'result',
result: {
syntheticResult: {
isError: true,
output: `Plan approval failed: ${message}`,
},
},
};
}
context.recordApprovalResult({
turnId: Number(context.toolCallContext.turnId),
toolCallId: context.toolCallContext.toolCall.id,
toolName: 'ExitPlanMode',
action,
result,
});
trackExitPlanModeResolution(context, result);
return exitPlanModeApprovalResult(context, result, review.options);
},
};
export const PlanModeGuardPermissionPolicy: PermissionPolicy = {
name: 'plan.mode-guard',
evaluate({ agent, toolCallContext }) {
if (!agent.planMode.isActive) return undefined;
const name = toolCallContext.toolCall.name;
const args = toolCallContext.args;
if (name === 'Write' || name === 'Edit') {
const path = readStringField(args, 'path');
if (path === undefined) return undefined;
const planFilePath = agent.planMode.planFilePath;
if (planFilePath !== null && path === planFilePath) return { kind: 'allow' };
return {
kind: 'result',
result: {
block: true,
reason:
`Plan mode is active. You may only write to the current plan file: ${planFilePath ?? '(no plan file selected yet)'}. ` +
'Call ExitPlanMode to exit plan mode before editing other files.',
},
};
}
if (name === 'TaskStop') {
return {
kind: 'result',
result: {
block: true,
reason:
'TaskStop is not available in plan mode. ' +
'Call ExitPlanMode to exit plan mode before stopping a background task.',
},
};
}
return undefined;
},
};
export function createPlanPermissionPolicies(): readonly PermissionPolicy[] {
return [
EnterPlanModePermissionPolicy,
ExitPlanModePermissionPolicy,
PlanModeGuardPermissionPolicy,
];
}
async function resolveExitPlanModeReview(context: PermissionPolicyContext): Promise<{
readonly plan: string;
readonly path?: string | undefined;
readonly options?: readonly ExitPlanModeOption[] | undefined;
} | null> {
if (!context.agent.planMode.isActive) return null;
let data: Awaited<ReturnType<PermissionPolicyContext['agent']['planMode']['data']>>;
try {
data = await context.agent.planMode.data();
} catch {
return null;
}
if (data === null || data.content.trim().length === 0) return null;
return {
plan: data.content,
path: data.path,
options: exitPlanModeOptions(context.toolCallContext.args),
};
}
function exitPlanModeApprovalResult(
context: PermissionPolicyContext,
result: ApprovalResponse,
options: readonly ExitPlanModeOption[] | undefined,
): PermissionPolicyResult {
if (result.decision === 'approved') {
const selected = selectedExitPlanModeOption(options, result.selectedLabel);
return {
kind: 'allow',
executionMetadata: exitPlanModeExecutionMetadata(selected),
};
}
if (result.decision === 'cancelled') {
return {
kind: 'result',
result: {
syntheticResult: {
isError: false,
output: 'Plan approval dismissed. Plan mode remains active.',
},
},
};
}
if (result.selectedLabel === 'Reject and Exit') {
const failed = exitPlanModeForRejectedPlan(context);
return {
kind: 'result',
result: {
syntheticResult:
failed ?? {
isError: true,
stopTurn: true,
output: 'Plan rejected by user. Plan mode deactivated.',
},
},
};
}
const feedback = result.feedback ?? '';
if (result.selectedLabel === 'Revise' || feedback.length > 0) {
return {
kind: 'result',
result: {
syntheticResult: {
isError: false,
output:
feedback.length > 0
? `User rejected the plan. Feedback:\n\n${feedback}`
: 'User requested revisions. Plan mode remains active.',
},
},
};
}
return {
kind: 'result',
result: {
syntheticResult: {
isError: true,
stopTurn: true,
output: 'Plan rejected by user. Plan mode remains active.',
},
},
};
}
function exitPlanModeExecutionMetadata(
selectedOption: ExitPlanModeOption | undefined,
): ExitPlanModeExecutionMetadata {
return {
selectedOption,
planTelemetrySubmitted: true,
planTelemetryResolved: true,
};
}
function trackExitPlanModeResolution(
context: PermissionPolicyContext,
result: ApprovalResponse,
): void {
const selectedLabel = result.selectedLabel ?? '';
const normalizedSelectedLabel = normalizeOptionLabel(selectedLabel);
const feedback = result.feedback ?? '';
const hasFeedback = feedback.length > 0;
if (result.decision === 'cancelled') {
context.agent.telemetry.track('plan_resolved', { outcome: 'dismissed' });
return;
}
if (result.decision === 'approved') {
if (selectedLabel.length > 0) {
context.agent.telemetry.track('plan_resolved', {
outcome: 'approved',
chosen_option: selectedLabel,
});
return;
}
context.agent.telemetry.track('plan_resolved', { outcome: 'approved' });
return;
}
if (normalizedSelectedLabel === 'reject and exit') {
context.agent.telemetry.track('plan_resolved', { outcome: 'rejected_and_exited' });
return;
}
if (normalizedSelectedLabel === 'revise' || hasFeedback) {
context.agent.telemetry.track('plan_resolved', {
outcome: 'revise',
has_feedback: hasFeedback,
});
return;
}
context.agent.telemetry.track('plan_resolved', { outcome: 'rejected' });
}
function exitPlanModeForRejectedPlan(
context: PermissionPolicyContext,
): ExecutableToolResult | undefined {
try {
context.agent.planMode.exit();
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to exit plan mode.';
return {
isError: true,
output: `Failed to exit plan mode: ${message}`,
};
}
}
function exitPlanModeOptions(args: unknown): readonly ExitPlanModeOption[] | undefined {
if (args === null || typeof args !== 'object') return undefined;
const options = (args as { readonly options?: unknown }).options;
if (!Array.isArray(options) || options.length < 2) return undefined;
const parsed: ExitPlanModeOption[] = [];
for (const option of options) {
if (option === null || typeof option !== 'object') return undefined;
const label = (option as { readonly label?: unknown }).label;
if (typeof label !== 'string') return undefined;
// `description` is optional in the ExitPlanMode schema (defaults to ''),
// so an option that omits it is still valid.
const description = (option as { readonly description?: unknown }).description;
if (description !== undefined && typeof description !== 'string') return undefined;
parsed.push({ label, description: description ?? '' });
}
return parsed;
}
function selectedExitPlanModeOption(
options: readonly ExitPlanModeOption[] | undefined,
label: string | undefined,
): ExitPlanModeOption | undefined {
if (options === undefined || label === undefined) return undefined;
return options.find((option) => option.label === label);
}
function exitPlanModeAction(options: readonly ExitPlanModeOption[] | undefined): string {
return options !== undefined && options.length >= 2
? 'Review plan and choose an option'
: 'Review plan';
}
function normalizeOptionLabel(label: string): string {
return label.trim().toLowerCase();
}
function readStringField(args: unknown, key: string): string | undefined {
if (args === null || typeof args !== 'object') return undefined;
const value = (args as Record<string, unknown>)[key];
return typeof value === 'string' ? value : undefined;
}

View file

@ -0,0 +1,27 @@
import type { Agent } from '../..';
import { isPlainRecord } from '../../turn/canonical-args';
import type { PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult } from '../types';
export class PreToolCallHookPermissionPolicy implements PermissionPolicy {
readonly name = 'pre-tool-call-hook';
constructor(private readonly agent: Agent) {}
async evaluate(context: PermissionPolicyContext): Promise<PermissionPolicyResult | undefined> {
const hookResult = await this.agent.hooks?.triggerBlock('PreToolUse', {
matcherValue: context.toolCall.name,
signal: context.signal,
inputData: {
toolName: context.toolCall.name,
toolInput: isPlainRecord(context.args) ? context.args : {},
toolCallId: context.toolCall.id,
},
});
context.signal.throwIfAborted();
if (hookResult === undefined) return;
return {
kind: 'deny',
message: hookResult.reason,
};
}
}

View file

@ -0,0 +1,46 @@
import type { Agent } from '../..';
import {
matchPermissionRule,
type PermissionRuleMatch,
} from '../matches-rule';
import type {
PermissionPolicy,
PermissionPolicyContext,
PermissionPolicyResult,
} from '../types';
export class SessionApprovalHistoryPermissionPolicy implements PermissionPolicy {
readonly name = 'session-approval-history';
constructor(private readonly agent: Agent) {}
evaluate(context: PermissionPolicyContext): PermissionPolicyResult | undefined {
const match = this.matchSessionApprovalRule(context);
if (match === undefined) return;
return {
kind: 'approve',
reason: {
has_rule_args: match.hasRuleArgs,
match_strategy: match.strategy,
},
};
}
private matchSessionApprovalRule(
context: PermissionPolicyContext,
): PermissionRuleMatch | undefined {
for (const pattern of this.agent.permission.sessionApprovalRulePatterns) {
const match = matchPermissionRule({
rule: {
decision: 'allow',
scope: 'session-runtime',
pattern,
reason: 'approve for session',
},
toolName: context.toolCall.name,
execution: context.execution,
});
if (match !== undefined) return match;
}
}
}

View file

@ -0,0 +1,115 @@
import type { Agent } from '../..';
import {
matchPermissionRule,
type PermissionRuleMatch,
} from '../matches-rule';
import type {
PermissionPolicy,
PermissionPolicyContext,
PermissionPolicyResult,
PermissionRule,
PermissionRuleDecision,
PermissionRuleScope,
} from '../types';
const USER_CONFIGURED_SCOPES = new Set<PermissionRuleScope>([
'turn-override',
'project',
'user',
]);
abstract class UserConfiguredPermissionPolicy {
constructor(protected readonly agent: Agent) {}
protected firstMatchingRule(
context: PermissionPolicyContext,
decision: PermissionRuleDecision,
): PermissionRuleMatch | undefined {
const rules = this.agent.permission.data().rules.filter((rule): rule is PermissionRule =>
USER_CONFIGURED_SCOPES.has(rule.scope),
);
for (const rule of rules) {
if (rule.decision !== decision) continue;
const match = matchPermissionRule({
rule,
toolName: context.toolCall.name,
execution: context.execution,
});
if (match !== undefined) return match;
}
return;
}
}
export class UserConfiguredDenyPermissionPolicy
extends UserConfiguredPermissionPolicy
implements PermissionPolicy
{
readonly name = 'user-configured-deny';
evaluate(context: PermissionPolicyContext): PermissionPolicyResult | undefined {
const match = this.firstMatchingRule(context, 'deny');
if (match === undefined) return;
return {
kind: 'deny',
reason: userRuleReason('deny', match),
message: formatPermissionRuleDenyMessage(
context.toolCall.name,
match.rule.reason,
this.agent.type,
),
};
}
}
export class UserConfiguredAllowPermissionPolicy
extends UserConfiguredPermissionPolicy
implements PermissionPolicy
{
readonly name = 'user-configured-allow';
evaluate(context: PermissionPolicyContext): PermissionPolicyResult | undefined {
const match = this.firstMatchingRule(context, 'allow');
if (match === undefined) return;
return {
kind: 'approve',
reason: userRuleReason('allow', match),
};
}
}
export class UserConfiguredAskPermissionPolicy
extends UserConfiguredPermissionPolicy
implements PermissionPolicy
{
readonly name = 'user-configured-ask';
evaluate(context: PermissionPolicyContext): PermissionPolicyResult | undefined {
const match = this.firstMatchingRule(context, 'ask');
if (match === undefined) return;
return {
kind: 'ask',
reason: userRuleReason('ask', match),
};
}
}
function userRuleReason(decision: PermissionRuleDecision, match: PermissionRuleMatch) {
return {
rule_decision: decision,
has_rule_args: match.hasRuleArgs,
match_strategy: match.strategy,
};
}
function formatPermissionRuleDenyMessage(
tool: string,
reason: string | undefined,
agentType?: Agent['type'],
): string {
const suffix = reason !== undefined && reason.length > 0 ? ` Reason: ${reason}` : '';
if (agentType === 'sub') {
return `Tool "${tool}" was denied.${suffix} Try a different approach — don't retry the same call, don't attempt to bypass the restriction.`;
}
return `Tool "${tool}" was denied by permission rule.${suffix}`;
}

View file

@ -0,0 +1,15 @@
import type { Agent } from '../..';
import type { PermissionPolicy, PermissionPolicyResult } from '../types';
export class YoloModeApprovePermissionPolicy implements PermissionPolicy {
readonly name = 'yolo-mode-approve';
constructor(private readonly agent: Agent) {}
evaluate(): PermissionPolicyResult | undefined {
if (this.agent.permission.mode !== 'yolo') return;
return {
kind: 'approve',
};
}
}

View file

@ -1,74 +0,0 @@
import type { ToolInputDisplay } from '../../../tools/display';
import {
DEFAULT_WORKSPACE_ACCESS_POLICY,
resolvePathAccess,
type PathAccessOperation,
} from '../../../tools/policies/path-access';
import type { PermissionPolicy } from '../policy';
type FileInputDisplayOperation = Extract<ToolInputDisplay, { kind: 'file_io' }>['operation'];
const FILE_ACCESS_TOOLS: Readonly<
Record<string, readonly [PathAccessOperation, FileInputDisplayOperation]>
> = {
Read: ['read', 'read'],
ReadMediaFile: ['read', 'read'],
Write: ['write', 'write'],
Edit: ['write', 'edit'],
Grep: ['search', 'grep'],
};
export const YoloOutsideWorkspacePermissionPolicy: PermissionPolicy = {
name: 'yolo.outside-workspace',
evaluate({ agent, mode, toolCallContext }) {
if (mode !== 'yolo') return undefined;
const toolName = toolCallContext.toolCall.name;
const toolAccess = FILE_ACCESS_TOOLS[toolName];
if (toolAccess === undefined) return undefined;
const [operation, displayOperation] = toolAccess;
const rawPath = readStringField(toolCallContext.args, 'path');
if (rawPath === undefined) return undefined;
let access;
try {
access = resolvePathAccess(
rawPath,
agent.config.cwd,
{
workspaceDir: agent.config.cwd,
additionalDirs: agent.skills?.registry.getSkillRoots() ?? [],
},
{
operation,
pathClass: agent.runtime.kaos.pathClass(),
homeDir: agent.runtime.kaos.gethome(),
policy: {
...DEFAULT_WORKSPACE_ACCESS_POLICY,
checkSensitive: toolName !== 'Grep',
},
},
);
} catch {
return undefined;
}
if (!access.outsideWorkspace) return undefined;
return {
kind: 'ask',
display: {
kind: 'file_io',
operation: displayOperation,
path: access.path,
detail: `Outside workspace: ${agent.config.cwd}`,
},
};
},
};
function readStringField(args: unknown, key: string): string | undefined {
if (args === null || typeof args !== 'object') return undefined;
const value = (args as Record<string, unknown>)[key];
return typeof value === 'string' ? value : undefined;
}

View file

@ -1,42 +0,0 @@
import type { Agent } from '..';
import type { PrepareToolExecutionResult, ToolExecutionHookContext } from '../../loop';
import type { ToolInputDisplay } from '../../tools/display';
import type { PermissionApprovalResultRecord, PermissionMode, PermissionRule } from './types';
export interface PermissionPolicyContext {
readonly agent: Agent;
readonly mode: PermissionMode;
readonly toolCallContext: ToolExecutionHookContext;
/**
* The rule matched by `checkPermission()`, if any.
*
* Policies that want to defer to user-defined rules (e.g. a default-allow
* policy that should not override an explicit `ask`/`deny` rule) inspect
* this to decide whether to fire. `undefined` means the decision came
* from the built-in default permission table rather than a user rule.
*/
readonly matchedRule: PermissionRule | undefined;
readonly recordApprovalResult: (record: PermissionApprovalResultRecord) => void;
}
export type PermissionPolicyResult =
| {
readonly kind: 'allow';
readonly executionMetadata?: unknown;
}
| {
readonly kind: 'result';
readonly result: PrepareToolExecutionResult;
}
| {
readonly kind: 'ask';
readonly action?: string | undefined;
readonly display?: ToolInputDisplay | undefined;
};
export interface PermissionPolicy {
readonly name: string;
evaluate(
context: PermissionPolicyContext,
): PermissionPolicyResult | undefined | Promise<PermissionPolicyResult | undefined>;
}

View file

@ -1,11 +1,12 @@
import type { PrepareToolExecutionResult, ResolvedToolExecutionHookContext } from '../../loop';
import type { ToolInputDisplay } from '../../tools/display';
export type PermissionRuleDecision = 'allow' | 'deny' | 'ask';
/**
* Rule provenance. `session-runtime` is the value used by the runtime
* "approve for session" path; `turn-override`, `project`, and `user`
* are reserved for static-loaded rules surfaced by external callers.
* Rule provenance. `session-runtime` stores rules produced by
* "approve for session"; `turn-override`, `project`, and `user` are
* reserved for static-loaded rules surfaced by external callers.
*/
export type PermissionRuleScope = 'turn-override' | 'session-runtime' | 'project' | 'user';
@ -22,14 +23,14 @@ export type PermissionMode = 'manual' | 'yolo' | 'auto';
/**
* A single permission rule. `pattern` is the DSL form (`Read(/etc/**)`,
* `Bash(rm *)`, or bare `Write`). See `parse-pattern.ts` for the parser
* and `matches-rule.ts` for the matcher.
* `Bash(rm *)`, or bare `Write`). Rule arguments are interpreted only by
* tools that provide a matcher; other tools match by name only.
*/
export interface PermissionRule {
readonly decision: PermissionRuleDecision;
readonly scope: PermissionRuleScope;
readonly pattern: string;
readonly reason?: string | undefined;
readonly reason?: string;
}
export interface ApprovalRequest {
@ -51,6 +52,7 @@ export interface PermissionApprovalResultRecord {
readonly toolCallId: string;
readonly toolName: string;
readonly action: string;
readonly sessionApprovalRule?: string;
readonly result: ApprovalResponse;
}
@ -58,3 +60,42 @@ export interface PermissionData {
mode: PermissionMode;
rules: PermissionRule[];
}
export type PermissionDecision = 'approve' | 'deny' | 'ask';
export type PermissionReasonValue = string | number | boolean | null;
export type PermissionDecisionReason = Readonly<Record<string, PermissionReasonValue>>;
export type PermissionPolicyResolution =
| PermissionPolicyResult
| ({ readonly kind: 'result' } & PrepareToolExecutionResult);
export interface PermissionPolicyContext extends ResolvedToolExecutionHookContext {}
export type PermissionPolicyResult =
| {
readonly kind: 'approve';
readonly reason?: PermissionDecisionReason;
readonly executionMetadata?: unknown;
}
| {
readonly kind: 'deny';
readonly reason?: PermissionDecisionReason;
readonly message?: string;
}
| {
readonly kind: 'ask';
readonly reason?: PermissionDecisionReason;
readonly resolveApproval?: (
result: ApprovalResponse,
) => PermissionPolicyResolution | undefined;
readonly resolveError?: (error: unknown) => PermissionPolicyResolution | undefined;
};
export interface PermissionPolicy {
readonly name: string;
evaluate(
context: PermissionPolicyContext,
): PermissionPolicyResult | undefined | Promise<PermissionPolicyResult | undefined>;
}

View file

@ -1,7 +1,8 @@
import { migrateV1_0ToV1_1 } from './v1.1';
import { migrateV1_1ToV1_2 } from './v1.2';
// Wire protocol versions currently support only the `number.number` format.
export const AGENT_WIRE_PROTOCOL_VERSION = '1.1';
export const AGENT_WIRE_PROTOCOL_VERSION = '1.2';
export interface WireMigrationRecord {
readonly type: string;
@ -14,7 +15,7 @@ export interface WireMigration {
migrateRecord(record: WireMigrationRecord): WireMigrationRecord;
}
const MIGRATIONS: readonly WireMigration[] = [migrateV1_0ToV1_1];
const MIGRATIONS: readonly WireMigration[] = [migrateV1_0ToV1_1, migrateV1_1ToV1_2];
export function isNewerWireVersion(readVersion: string): boolean {
return compareWireVersions(readVersion, AGENT_WIRE_PROTOCOL_VERSION) > 0;

View file

@ -6,22 +6,27 @@ import type { WireMigration, WireMigrationRecord } from './index';
* v1.1 flattens it to:
* { name: 'xxx', arguments: 'yyy' }
*/
interface LegacyToolCall {
type: 'function';
id: string;
function: {
name?: string;
arguments?: string | null;
interface V1_0ContextAppendMessageRecord extends WireMigrationRecord {
readonly type: 'context.append_message';
readonly message: V1_0ContextMessage;
}
interface V1_0ContextMessage {
readonly toolCalls: readonly V1_0ToolCall[];
readonly [key: string]: unknown;
}
interface V1_0ToolCall {
readonly type: 'function';
readonly id: string;
readonly function: {
readonly name?: string;
readonly arguments?: string | null;
};
}
function isLegacyToolCall(v: unknown): v is LegacyToolCall {
if (!isRecord(v)) return false;
return v['type'] === 'function' && typeof v['id'] === 'string' && isRecord(v['function']);
}
function migrateToolCall(v: LegacyToolCall): unknown {
const { function: fn, ...rest } = v;
function migrateToolCall(toolCall: V1_0ToolCall): WireMigrationRecord {
const { function: fn, ...rest } = toolCall;
return {
...rest,
name: fn.name,
@ -29,34 +34,18 @@ function migrateToolCall(v: LegacyToolCall): unknown {
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export const migrateV1_0ToV1_1: WireMigration = {
sourceVersion: '1.0',
targetVersion: '1.1',
migrateRecord(record: WireMigrationRecord): WireMigrationRecord {
if (record.type !== 'context.append_message') return record;
const message = record['message'] as {
readonly toolCalls: readonly unknown[];
};
let changed = false;
const toolCalls = message.toolCalls.map((toolCall) => {
if (!isLegacyToolCall(toolCall)) return toolCall;
changed = true;
return migrateToolCall(toolCall);
});
if (!changed) return record;
const appendMessageRecord = record as V1_0ContextAppendMessageRecord;
return {
...record,
...appendMessageRecord,
message: {
...message,
toolCalls,
...appendMessageRecord.message,
toolCalls: appendMessageRecord.message.toolCalls.map(migrateToolCall),
},
};
},

View file

@ -0,0 +1,63 @@
import type { WireMigration, WireMigrationRecord } from './index';
interface V1_1ApprovalResult {
readonly decision: 'approved' | 'rejected' | 'cancelled';
readonly scope?: 'session';
readonly feedback?: string;
readonly selectedLabel?: string;
}
interface V1_1ApprovalResultRecord extends WireMigrationRecord {
readonly type: 'permission.record_approval_result';
readonly turnId: number;
readonly toolCallId: string;
readonly toolName: string;
readonly action: string;
readonly sessionApprovalRule?: string;
readonly result: V1_1ApprovalResult;
}
const LEGACY_SESSION_APPROVAL_ACTION_TO_PATTERN: Readonly<Record<string, string>> = {
'run command': 'Bash',
'stop background task': 'TaskStop',
'edit file': 'Write',
'edit file outside of working directory': 'Write',
'write file': 'Write',
};
// v1.1 cached these action labels directly but did not have enough stable data
// to reconstruct an equivalent v1.2 rule. Migrating to broad `Bash` would
// expand the approval, and there is no safe `Bash(...)` subject to recover —
// in particular, `run background command` would need to encode
// `run_in_background=true`, which `Bash`'s `matchesRule` cannot express.
const LEGACY_SESSION_APPROVAL_UNRESTORABLE_ACTIONS = new Set<string>([
'run command in plan mode',
'run background command',
]);
export const migrateV1_1ToV1_2: WireMigration = {
sourceVersion: '1.1',
targetVersion: '1.2',
migrateRecord(record: WireMigrationRecord): WireMigrationRecord {
if (record.type !== 'permission.record_approval_result') return record;
const approvalRecord = record as V1_1ApprovalResultRecord;
if (
approvalRecord.result.decision !== 'approved' ||
approvalRecord.result.scope !== 'session'
) {
return record;
}
if (approvalRecord.sessionApprovalRule !== undefined) return record;
const pattern = LEGACY_SESSION_APPROVAL_UNRESTORABLE_ACTIONS.has(approvalRecord.action)
? undefined
: LEGACY_SESSION_APPROVAL_ACTION_TO_PATTERN[approvalRecord.action] ??
approvalRecord.toolName;
if (pattern === undefined) return record;
return {
...record,
sessionApprovalRule: pattern,
};
},
};

View file

@ -1,8 +1,8 @@
import { uniq } from '@antfu/utils';
import type { ChatProvider, Tool } from '@moonshot-ai/kosong';
import picomatch from 'picomatch';
import type { Agent } from '..';
import { globMatch } from '../permission/path-glob-match';
import { makeErrorPayload } from '../../errors';
import type { ExecutableTool } from '../../loop';
import { createMcpAuthTool } from '../../mcp/auth-tool';
@ -92,6 +92,7 @@ export class ToolManager {
parameters,
resolveExecution: (args) => {
return {
approvalRule: name,
execute: async (context) => {
return this.agent.rpc.toolCall(
{
@ -156,6 +157,7 @@ export class ToolManager {
parameters: tool.parameters,
resolveExecution: (args) => {
return {
approvalRule: qualified,
execute: async (context) => {
// `args` has already been JSON-parsed and schema-validated by
// the loop's preflight (`loop/tool-call.ts`), so the MCP
@ -298,7 +300,7 @@ export class ToolManager {
}
private isMcpToolEnabled(name: string): boolean {
return this.mcpAccessPatterns.some((pattern) => globMatch(name, pattern));
return this.mcpAccessPatterns.some((pattern) => picomatch.isMatch(name, pattern));
}
*toolInfos(): Iterable<ToolInfo> {

View file

@ -419,29 +419,10 @@ export class TurnFlow {
ctx.args,
);
if (cached !== null) return { syntheticResult: cached };
const hookResult = await this.agent.hooks?.triggerBlock('PreToolUse', {
matcherValue: ctx.toolCall.name,
signal: ctx.signal,
inputData: {
toolName: ctx.toolCall.name,
toolInput: toolInputRecord(ctx.args),
toolCallId: ctx.toolCall.id,
},
});
ctx.signal.throwIfAborted();
if (hookResult) {
this.agent.telemetry.track('hook_triggered', {
event_type: 'PreToolUse',
action: hookResult.block ? 'block' : 'allow',
});
return hookResult;
}
const permissionResult = await this.agent.permission.beforeToolCall(ctx);
this.agent.telemetry.track('hook_triggered', {
event_type: 'PreToolUse',
action: permissionResult?.block === true ? 'block' : 'allow',
});
return permissionResult;
return undefined;
},
authorizeToolExecution: async (ctx) => {
return this.agent.permission.beforeToolCall(ctx);
},
finalizeToolResult: async (ctx) => {
// Resolve dedup BEFORE firing the PostToolUse hook so same-step

View file

@ -1,5 +1,5 @@
import { HOOK_EVENT_TYPES } from '#/agent/hooks/types';
import { parsePattern } from '#/agent/permission/parse-pattern';
import { parsePattern } from '#/agent/permission/matches-rule';
import { ErrorCodes, KimiError } from '#/errors';
import { z } from 'zod';

View file

@ -25,7 +25,9 @@ export type {
ToolCall,
ExecutableToolContext,
ToolExecutionHookContext,
ResolvedToolExecutionHookContext,
PrepareToolExecutionHook,
AuthorizeToolExecutionHook,
PrepareToolExecutionResult,
ExecutableToolResult,
FinalizeToolResultContext,

View file

@ -1,25 +1,22 @@
export type ToolFileAccessOperation = 'read' | 'write' | 'readwrite' | 'search';
export type ToolResourceAccess =
| {
readonly kind: 'file';
readonly operation: ToolFileAccessOperation;
/**
* Canonical file path when the access can be narrowed. Omitted means the
* operation may touch any file in the backend file namespace.
*/
readonly path?: string;
readonly recursive?: boolean;
}
| {
/**
* Arbitrary side effects or resources that cannot be represented as a
* file access. This is intentionally operation-less and globally
* exclusive for concurrency.
*/
readonly kind: 'all';
};
export interface ToolFileAccess {
readonly kind: 'file';
readonly operation: ToolFileAccessOperation;
readonly path: string;
readonly recursive?: boolean;
}
export interface ToolResourceAccessAll {
/**
* Arbitrary side effects or resources that cannot be represented as a
* file access. This is intentionally operation-less and globally
* exclusive for concurrency.
*/
readonly kind: 'all';
}
export type ToolResourceAccess = ToolFileAccess | ToolResourceAccessAll;
export type ToolAccesses = readonly ToolResourceAccess[];
export const ToolAccesses = {
@ -33,18 +30,10 @@ export const ToolAccesses = {
file(
operation: ToolFileAccessOperation,
path?: string,
path: string,
options: { readonly recursive?: boolean } = {},
): ToolAccesses {
const access: {
kind: 'file';
operation: ToolFileAccessOperation;
path?: string;
recursive?: boolean;
} = { kind: 'file', operation };
if (path !== undefined) access.path = path;
if (options.recursive !== undefined) access.recursive = options.recursive;
return [access];
return [{ kind: 'file', operation, path, recursive: options.recursive }];
},
readFile(path: string): ToolAccesses {
@ -55,10 +44,6 @@ export const ToolAccesses = {
return ToolAccesses.file('read', path, { recursive: true });
},
readAnyFile(): ToolAccesses {
return ToolAccesses.file('read');
},
writeFile(path: string): ToolAccesses {
return ToolAccesses.file('write', path);
},
@ -67,10 +52,6 @@ export const ToolAccesses = {
return ToolAccesses.file('write', path, { recursive: true });
},
writeAnyFile(): ToolAccesses {
return ToolAccesses.file('write');
},
readWriteFile(path: string): ToolAccesses {
return ToolAccesses.file('readwrite', path);
},
@ -79,18 +60,10 @@ export const ToolAccesses = {
return ToolAccesses.file('readwrite', path, { recursive: true });
},
readWriteAnyFile(): ToolAccesses {
return ToolAccesses.file('readwrite');
},
searchTree(path: string): ToolAccesses {
return ToolAccesses.file('search', path, { recursive: true });
},
searchAnyFile(): ToolAccesses {
return ToolAccesses.file('search');
},
conflict(left: ToolAccesses, right: ToolAccesses): boolean {
return left.some((leftAccess) =>
right.some((rightAccess) => resourceAccessesConflict(leftAccess, rightAccess)),
@ -122,12 +95,7 @@ function fileOperationWrites(operation: ToolFileAccessOperation): boolean {
}
}
function fileAccessesOverlap(
left: Extract<ToolResourceAccess, { kind: 'file' }>,
right: Extract<ToolResourceAccess, { kind: 'file' }>,
): boolean {
if (left.path === undefined || right.path === undefined) return true;
function fileAccessesOverlap(left: ToolFileAccess, right: ToolFileAccess): boolean {
const leftPath = normalizePath(left.path);
const rightPath = normalizePath(right.path);
if (leftPath === rightPath) return true;

View file

@ -30,6 +30,7 @@ import type { LLM, LLMChatResponse } from './llm';
import { ToolAccesses } from './tool-access';
import { ToolScheduler, type ToolCallTask } from './tool-scheduler';
import type {
AuthorizeToolExecutionResult,
ExecutableTool,
LoopHooks,
ToolCall,
@ -223,46 +224,50 @@ async function prepareToolCall(
step: ToolCallStepContext,
call: PreflightedToolCall,
): Promise<PreparedToolCallTask> {
if (call.kind === 'rejected') {
await dispatchToolCall(step, call, call.args);
return { task: makeResolvedToolCallTask(makeErrorToolResult(call, call.args, call.output)) };
}
const settleError = async (
args: unknown,
output: string,
displayFields?: ToolCallDisplayFields,
): Promise<PreparedToolCallTask> => {
await dispatchToolCall(step, call, args, displayFields);
return { task: makeResolvedToolCallTask(makeErrorToolResult(call, args, output)) };
};
const decision = await runPrepareToolExecutionHook(step, call);
if (decision.kind === 'blocked') {
await dispatchToolCall(step, call, decision.args);
const settleSynthetic = async (
args: unknown,
result: ExecutableToolResult,
displayFields?: ToolCallDisplayFields,
): Promise<PreparedToolCallTask> => {
const coerced = coerceToolResult(result, call.toolName);
await dispatchToolCall(step, call, args, displayFields);
return {
task: makeResolvedToolCallTask(makeErrorToolResult(call, decision.args, decision.output)),
};
}
if (decision.kind === 'hookFailed') {
await dispatchToolCall(step, call, decision.args);
return {
task: makeResolvedToolCallTask(makeErrorToolResult(call, decision.args, decision.output)),
};
}
if (decision.kind === 'synthetic') {
await dispatchToolCall(step, call, decision.args);
const coerced = coerceToolResult(decision.result, call.toolName);
return {
task: makeResolvedToolCallTask(makeToolResult(call, decision.args, coerced)),
task: makeResolvedToolCallTask(makeToolResult(call, args, coerced)),
stopBatchAfterThis: toolResultStopsTurn(coerced),
};
};
if (call.kind === 'rejected') return settleError(call.args, call.output);
const decision = await runPrepareToolExecutionHook(step, call);
if (decision.kind === 'blocked' || decision.kind === 'hookFailed') {
return settleError(decision.args, decision.output);
}
if (decision.kind === 'synthetic') {
return settleSynthetic(decision.args, decision.result);
}
const validationError = validateExecutableToolArgs(call.tool, decision.args);
if (validationError !== null) {
await dispatchToolCall(step, call, decision.args);
const output = `Invalid args for tool "${call.toolName}" after prepareToolExecution hook: ${validationError}`;
return { task: makeResolvedToolCallTask(makeErrorToolResult(call, decision.args, output)) };
return settleError(
decision.args,
`Invalid args for tool "${call.toolName}" after prepareToolExecution hook: ${validationError}`,
);
}
const effectiveArgs = decision.args;
let execution: ToolExecution;
try {
execution = call.tool.resolveExecution(effectiveArgs);
execution = await call.tool.resolveExecution(effectiveArgs);
} catch (error) {
if (!(error instanceof PathSecurityError)) {
step.log?.warn('tool execution setup failed', {
@ -275,35 +280,41 @@ async function prepareToolCall(
error instanceof PathSecurityError
? error.message
: `Tool "${call.toolName}" failed to resolve execution: ${errorMessage(error)}`;
await dispatchToolCall(step, call, effectiveArgs);
return {
task: makeResolvedToolCallTask(makeErrorToolResult(call, effectiveArgs, output)),
};
return settleError(effectiveArgs, output);
}
const displayFields = toolCallDisplayFieldsFromExecution(execution);
await dispatchToolCall(step, call, effectiveArgs, displayFields);
const settleAborted = (): Promise<PreparedToolCallTask> =>
settleError(effectiveArgs, `Tool "${call.toolName}" was aborted`, displayFields);
if (step.signal.aborted) {
return {
task: makeResolvedToolCallTask(
makeErrorToolResult(call, effectiveArgs, `Tool "${call.toolName}" was aborted`),
),
};
}
if (step.signal.aborted) return settleAborted();
if (execution.isError === true) {
return {
task: makeResolvedToolCallTask(makeToolResult(call, effectiveArgs, execution)),
stopBatchAfterThis: execution.stopTurn,
};
return settleSynthetic(effectiveArgs, execution, displayFields);
}
const authorization = await runAuthorizeToolExecutionHook(step, call, effectiveArgs, execution);
if (step.signal.aborted) return settleAborted();
if (authorization?.block === true) {
return settleError(
effectiveArgs,
authorization.reason ?? `Tool call "${call.toolName}" was blocked`,
displayFields,
);
}
if (authorization?.syntheticResult !== undefined) {
return settleSynthetic(effectiveArgs, authorization.syntheticResult, displayFields);
}
const executionMetadata = authorization?.executionMetadata ?? decision.metadata;
await dispatchToolCall(step, call, effectiveArgs, displayFields);
return {
task: {
accesses: execution.accesses ?? ToolAccesses.all(),
start: async () => ({
result: runRunnableToolCall(step, call, effectiveArgs, decision.metadata, execution),
result: runRunnableToolCall(step, call, effectiveArgs, executionMetadata, execution),
}),
},
};
@ -384,6 +395,40 @@ async function runPrepareToolExecutionHook(
return { kind: 'allowed', args: effectiveArgs, metadata: hookResult?.executionMetadata };
}
async function runAuthorizeToolExecutionHook(
step: ToolCallStepContext,
call: RunnableToolCall,
args: unknown,
execution: RunnableToolExecution,
): Promise<AuthorizeToolExecutionResult | undefined> {
const { hooks, signal, turnId, currentStep, llm } = step;
if (hooks?.authorizeToolExecution === undefined) return undefined;
try {
return await hooks.authorizeToolExecution({
toolCall: call.toolCall,
tool: call.tool,
args,
execution,
turnId,
stepNumber: currentStep,
signal,
llm,
});
} catch (error) {
if (isAbortError(error) || signal.aborted) {
return {
block: true,
reason: `Tool "${call.toolName}" was aborted during authorizeToolExecution hook`,
};
}
return {
block: true,
reason: `authorizeToolExecution hook failed for "${call.toolName}": ${errorMessage(error)}`,
};
}
}
function toolCallDisplayFieldsFromExecution(
execution: ToolExecution,
): ToolCallDisplayFields | undefined {

View file

@ -115,13 +115,15 @@ export interface RunnableToolExecution {
readonly accesses?: ToolAccesses | undefined;
readonly display?: ToolInputDisplay | undefined;
readonly description?: string;
readonly approvalRule: string;
readonly matchesRule?: ((ruleArgs: string) => boolean) | undefined;
readonly execute: (ctx: ExecutableToolContext) => Promise<ExecutableToolResult>;
}
export type ToolExecution = RunnableToolExecution | ExecutableToolErrorResult;
export interface ExecutableTool<Input = unknown> extends Tool {
resolveExecution(input: Input): ToolExecution;
resolveExecution(input: Input): ToolExecution | Promise<ToolExecution>;
}
/**
@ -142,14 +144,21 @@ export interface ToolExecutionHookContext extends LoopStepHookContext {
readonly args: unknown;
}
export interface PrepareToolExecutionResult {
export interface ResolvedToolExecutionHookContext extends ToolExecutionHookContext {
readonly execution: RunnableToolExecution;
}
export interface AuthorizeToolExecutionResult {
readonly block?: boolean | undefined;
readonly reason?: string | undefined;
readonly updatedArgs?: unknown;
readonly syntheticResult?: ExecutableToolResult | undefined;
readonly executionMetadata?: unknown;
}
export interface PrepareToolExecutionResult extends AuthorizeToolExecutionResult {
readonly updatedArgs?: unknown;
}
export interface FinalizeToolResultContext extends ToolExecutionHookContext {
readonly result: ExecutableToolResult;
}
@ -181,6 +190,10 @@ export type PrepareToolExecutionHook = (
ctx: ToolExecutionHookContext,
) => Promise<PrepareToolExecutionResult | undefined>;
export type AuthorizeToolExecutionHook = (
ctx: ResolvedToolExecutionHookContext,
) => Promise<AuthorizeToolExecutionResult | undefined>;
export type FinalizeToolResultHook = (
ctx: FinalizeToolResultContext,
) => Promise<ExecutableToolResult | undefined>;
@ -203,6 +216,7 @@ export interface LoopHooks {
beforeStep?: BeforeStepHook | undefined;
afterStep?: AfterStepHook | undefined;
prepareToolExecution?: PrepareToolExecutionHook | undefined;
authorizeToolExecution?: AuthorizeToolExecutionHook | undefined;
finalizeToolResult?: FinalizeToolResultHook | undefined;
shouldContinueAfterStop?: ShouldContinueAfterStopHook | undefined;
}

View file

@ -158,6 +158,7 @@ export function createMcpAuthTool(options: CreateMcpAuthToolOptions): Executable
resolveExecution: () => {
return {
description: `Authenticating ${serverName}`,
approvalRule: name,
execute,
};
},

View file

@ -1,4 +1,4 @@
export type TelemetryPropertyValue = boolean | number | string | null;
export type TelemetryPropertyValue = boolean | number | string | undefined | null;
export type TelemetryProperties = Readonly<Record<string, TelemetryPropertyValue>>;

View file

@ -7,6 +7,7 @@ import { z } from 'zod';
import type { BuiltinTool } from '../../agent/tool';
import type { ToolExecution } from '../../loop/types';
import { toInputJsonSchema } from '../support/input-schema';
import { matchesGlobRuleSubject } from '../support/rule-match';
import type { BackgroundProcessManager, BackgroundTaskInfo } from './manager';
import { isBackgroundTaskTerminal } from './manager';
import TASK_LIST_DESCRIPTION from './task-list.md';
@ -67,8 +68,11 @@ export class TaskListTool implements BuiltinTool<TaskListInput> {
constructor(private readonly manager: BackgroundProcessManager) {}
resolveExecution(args: TaskListInput): ToolExecution {
const listScope = (args.active_only ?? true) ? 'active' : 'all';
return {
description: 'Listing background tasks',
approvalRule: this.name,
matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, listScope),
execute: async () => {
await this.manager.settlePendingExits();
const activeOnly = args.active_only ?? true;

View file

@ -22,6 +22,7 @@ import {
type BackgroundTaskStatus,
} from './manager';
import { toInputJsonSchema } from '../support/input-schema';
import { matchesGlobRuleSubject } from '../support/rule-match';
import TASK_OUTPUT_DESCRIPTION from './task-output.md';
/**
@ -75,6 +76,8 @@ export class TaskOutputTool implements BuiltinTool<TaskOutputInput> {
resolveExecution(args: TaskOutputInput): ToolExecution {
return {
description: `Reading output of task ${args.task_id}`,
approvalRule: this.name,
matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.task_id),
execute: () => this.execute(args),
};
}

View file

@ -7,6 +7,7 @@ import { z } from 'zod';
import type { BuiltinTool } from '../../agent/tool';
import type { ToolExecution } from '../../loop/types';
import { toInputJsonSchema } from '../support/input-schema';
import { matchesGlobRuleSubject } from '../support/rule-match';
import { isBackgroundTaskTerminal, type BackgroundProcessManager } from './manager';
import TASK_STOP_DESCRIPTION from './task-stop.md';
@ -35,6 +36,8 @@ export class TaskStopTool implements BuiltinTool<TaskStopInput> {
resolveExecution(args: TaskStopInput): ToolExecution {
return {
description: `Stopping task ${args.task_id}`,
approvalRule: this.name,
matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.task_id),
execute: async () => {
await this.manager.settlePendingExits();
const info = this.manager.getTask(args.task_id);

View file

@ -27,6 +27,7 @@ import type { SessionSubagentHost, SubagentHandle } from '../../../session/subag
import { createDeadlineAbortSignal, type DeadlineAbortSignal } from '../../../utils/abort';
import type { BackgroundProcessManager } from '../../background/manager';
import { toInputJsonSchema } from '../../support/input-schema';
import { matchesGlobRuleSubject } from '../../support/rule-match';
import AGENT_BACKGROUND_DISABLED_DESCRIPTION from './agent-background-disabled.md';
import AGENT_BACKGROUND_DESCRIPTION from './agent-background-enabled.md';
import AGENT_DESCRIPTION_BASE from './agent.md';
@ -144,6 +145,14 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
return {
description: `${prefix} ${profileName} agent: ${args.description}`,
accesses: ToolAccesses.none(),
display: {
kind: 'agent_call',
agent_name: profileName,
prompt: args.prompt,
background: args.run_in_background,
},
approvalRule: this.name,
matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, profileName),
execute: (ctx) => this.execution(args, ctx),
};
}

View file

@ -90,6 +90,7 @@ export class AskUserQuestionTool implements BuiltinTool<AskUserQuestionInput> {
resolveExecution(args: AskUserQuestionInput): ToolExecution {
return {
description: 'Asking user questions',
approvalRule: this.name,
execute: (ctx) => this.execution(args, ctx),
};
}

View file

@ -22,6 +22,7 @@ import type { ExecutableToolResult, ToolExecution } from '../../../loop/types';
import { isInlineSkillType, type SkillDefinition } from '../../../skill';
import { renderPrompt } from '../../../utils/render-prompt';
import { toInputJsonSchema } from '../../support/input-schema';
import { matchesGlobRuleSubject } from '../../support/rule-match';
import skillDescriptionTemplate from './skill-tool.md';
export const MAX_SKILL_QUERY_DEPTH = 3;
@ -78,6 +79,9 @@ export class SkillTool implements BuiltinTool<SkillToolInput> {
resolveExecution(args: SkillToolInput): ToolExecution {
return {
description: `Invoke skill ${args.skill}`,
display: { kind: 'skill_call', skill_name: args.skill, args: args.args },
approvalRule: this.name,
matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.skill),
execute: () => this.execution(args),
};
}

View file

@ -16,6 +16,7 @@ import { ToolAccesses } from '../../../loop/tool-access';
import type { ExecutableToolResult, ToolExecution } from '../../../loop/types';
import { resolvePathAccessPath } from '../../policies/path-access';
import { toInputJsonSchema } from '../../support/input-schema';
import { literalRulePattern, matchesPathRuleSubject } from '../../support/rule-match';
import type { WorkspaceConfig } from '../../support/workspace';
import { materializeModelText, toModelTextView } from './line-endings';
import EDIT_DESCRIPTION from './edit.md';
@ -73,6 +74,14 @@ export class EditTool implements BuiltinTool<EditInput> {
return {
accesses: ToolAccesses.readWriteFile(path),
description: `Editing ${args.path}`,
display: { kind: 'file_io', operation: 'edit', path },
approvalRule: literalRulePattern(this.name, path),
matchesRule: (ruleArgs) =>
matchesPathRuleSubject(ruleArgs, path, {
cwd: this.workspace.workspaceDir,
pathClass: this.kaos.pathClass(),
homeDir: this.kaos.gethome(),
}),
execute: () => this.execution(args, path),
};
}

View file

@ -39,6 +39,7 @@ import { resolvePathAccessPath } from '../../policies/path-access';
import type { PathClass } from '../../policies/path-access';
import { toInputJsonSchema } from '../../support/input-schema';
import { listDirectory } from '../../support/list-directory';
import { literalRulePattern, matchesGlobRuleSubject } from '../../support/rule-match';
import type { WorkspaceConfig } from '../../support/workspace';
import GLOB_DESCRIPTION from './glob.md';
@ -118,6 +119,9 @@ export class GlobTool implements BuiltinTool<GlobInput> {
return {
accesses: ToolAccesses.searchTree(searchRoots[0]!),
description: `Searching ${args.pattern}`,
display: { kind: 'file_io', operation: 'glob', path: searchRoots[0]! },
approvalRule: literalRulePattern(this.name, args.pattern),
matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.pattern),
execute: () => this.execution(args, searchRoots),
};
}

View file

@ -32,6 +32,7 @@ import type { PathClass } from '../../policies/path-access';
import { isSensitiveFile, SENSITIVE_DOT_VARIANT_SUFFIXES } from '../../policies/sensitive';
import { toInputJsonSchema } from '../../support/input-schema';
import { ensureRgPath, rgUnavailableMessage } from '../../support/rg-locator';
import { literalRulePattern, matchesGlobRuleSubject } from '../../support/rule-match';
import { ToolResultBuilder } from '../../support/result-builder';
import type { WorkspaceConfig } from '../../support/workspace';
import GREP_DESCRIPTION from './grep.md';
@ -191,6 +192,9 @@ export class GrepTool implements BuiltinTool<GrepInput> {
return {
accesses: ToolAccesses.searchTree(searchPaths[0]!),
description: `Searching for '${args.pattern}' in ${searchPath}`,
display: { kind: 'file_io', operation: 'grep', path: searchPaths[0]! },
approvalRule: literalRulePattern(this.name, args.pattern),
matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.pattern),
execute: ({ signal }) => this.execution(args, signal, searchPaths),
};
}
@ -839,7 +843,7 @@ function filterSensitiveLines(
continue;
}
const filePath = parsedFilePath(line, mode, pathClass);
if (filePath !== undefined && isSensitiveFile(filePath, pathClass)) {
if (filePath !== undefined && isSensitiveFile(filePath)) {
filteredPaths.add(filePath);
continue;
}

View file

@ -31,6 +31,7 @@ import { renderPrompt } from '../../../utils/render-prompt';
import { resolvePathAccessPath } from '../../policies/path-access';
import { MEDIA_SNIFF_BYTES, detectFileType, sniffImageDimensions } from '../../support/file-type';
import { toInputJsonSchema } from '../../support/input-schema';
import { literalRulePattern, matchesPathRuleSubject } from '../../support/rule-match';
import type { WorkspaceConfig } from '../../support/workspace';
import readMediaDescriptionHead from './read-media.md';
@ -149,6 +150,14 @@ export class ReadMediaFileTool implements BuiltinTool<ReadMediaFileInput> {
return {
accesses: ToolAccesses.readFile(path),
description: `Reading media: ${args.path}`,
display: { kind: 'file_io', operation: 'read', path },
approvalRule: literalRulePattern(this.name, path),
matchesRule: (ruleArgs) =>
matchesPathRuleSubject(ruleArgs, path, {
cwd: this.workspace.workspaceDir,
pathClass: this.kaos.pathClass(),
homeDir: this.kaos.gethome(),
}),
execute: () => this.execution(args, path),
};
}

View file

@ -8,6 +8,7 @@ import { renderPrompt } from '../../../utils/render-prompt';
import { resolvePathAccessPath } from '../../policies/path-access';
import { MEDIA_SNIFF_BYTES, detectFileType } from '../../support/file-type';
import { toInputJsonSchema } from '../../support/input-schema';
import { literalRulePattern, matchesPathRuleSubject } from '../../support/rule-match';
import type { WorkspaceConfig } from '../../support/workspace';
import { makeCarriageReturnsVisible, type LineEndingStyle } from './line-endings';
import readDescriptionTemplate from './read.md';
@ -183,6 +184,14 @@ export class ReadTool implements BuiltinTool<ReadInput> {
return {
accesses: ToolAccesses.readFile(path),
description: `Reading ${args.path}`,
display: { kind: 'file_io', operation: 'read', path },
approvalRule: literalRulePattern(this.name, path),
matchesRule: (ruleArgs) =>
matchesPathRuleSubject(ruleArgs, path, {
cwd: this.workspace.workspaceDir,
pathClass: this.kaos.pathClass(),
homeDir: this.kaos.gethome(),
}),
execute: () => this.execution(args, path),
};
}

View file

@ -14,6 +14,7 @@ import { ToolAccesses } from '../../../loop/tool-access';
import type { ExecutableToolResult, ToolExecution } from '../../../loop/types';
import { resolvePathAccessPath } from '../../policies/path-access';
import { toInputJsonSchema } from '../../support/input-schema';
import { literalRulePattern, matchesPathRuleSubject } from '../../support/rule-match';
import type { WorkspaceConfig } from '../../support/workspace';
import WRITE_DESCRIPTION from './write.md';
@ -68,6 +69,14 @@ export class WriteTool implements BuiltinTool<WriteInput> {
return {
accesses: ToolAccesses.writeFile(path),
description: `Writing ${args.path}`,
display: { kind: 'file_io', operation: 'write', path },
approvalRule: literalRulePattern(this.name, path),
matchesRule: (ruleArgs) =>
matchesPathRuleSubject(ruleArgs, path, {
cwd: this.workspace.workspaceDir,
pathClass: this.kaos.pathClass(),
homeDir: this.kaos.gethome(),
}),
execute: () => this.execution(args, path),
};
}

View file

@ -29,6 +29,7 @@ export class EnterPlanModeTool implements BuiltinTool<EnterPlanModeInput> {
resolveExecution(_args: EnterPlanModeInput): ToolExecution {
return {
description: 'Requesting to enter plan mode',
approvalRule: this.name,
execute: async () => {
// Guard: already in plan mode
if (this.agent.planMode.isActive) {

View file

@ -3,16 +3,16 @@
*
* The LLM calls this tool to surface a finalised plan to the user and
* exit plan mode. The plan must already be written to the current plan
* file; this tool reads that file and flips plan mode off. PermissionManager
* handles plan approval before this tool runs and passes any selected option
* through execution metadata.
* file; this tool reads that file and flips plan mode off.
*/
import type { Agent } from '#/agent';
import type { PlanData } from '#/agent/plan';
import { z } from 'zod';
import type { BuiltinTool } from '../../../agent/tool';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types';
import type { ExecutableToolResult, ToolExecution } from '../../../loop/types';
import type { ToolInputDisplay } from '../../display';
import { toInputJsonSchema } from '../../support/input-schema';
import DESCRIPTION from './exit-plan-mode.md';
@ -86,19 +86,38 @@ export class ExitPlanModeTool implements BuiltinTool<ExitPlanModeInput> {
constructor(private readonly agent: Agent) {}
resolveExecution(args: ExitPlanModeInput): ToolExecution {
async resolveExecution(args: ExitPlanModeInput): Promise<ToolExecution> {
return {
description: 'Presenting plan and exiting plan mode',
execute: (ctx) => this.execution(args, ctx),
display: await this.resolvePlanReviewDisplay(args),
approvalRule: this.name,
execute: () => this.execution(args),
};
}
private async execution(
private async resolvePlanReviewDisplay(
args: ExitPlanModeInput,
{
metadata,
}: ExecutableToolContext,
): Promise<ExecutableToolResult> {
): Promise<ToolInputDisplay | undefined> {
if (!this.agent.planMode.isActive) return undefined;
let data: PlanData;
try {
data = await this.agent.planMode.data();
} catch {
return undefined;
}
if (data === null || data.content.trim().length === 0) return undefined;
const display: ToolInputDisplay = {
kind: 'plan_review',
plan: data.content,
path: data.path,
};
if (args.options !== undefined && args.options.length >= 2) {
display.options = args.options;
}
return display;
}
private async execution(args: ExitPlanModeInput): Promise<ExecutableToolResult> {
if (!this.agent.planMode.isActive) {
return {
isError: true,
@ -110,38 +129,18 @@ export class ExitPlanModeTool implements BuiltinTool<ExitPlanModeInput> {
const resolvedPlan = await this.resolvePlan();
if (!resolvedPlan.ok) return resolvedPlan.error;
if (!planTelemetryWasSubmitted(metadata)) {
this.agent.telemetry.track('plan_submitted', {
has_options: args.options !== undefined && args.options.length >= 2,
});
}
return this.exitWithPlan(
resolvedPlan.plan,
resolvedPlan.path,
selectedOptionFromMetadata(metadata),
planTelemetryWasResolved(metadata),
);
}
this.agent.telemetry.track('plan_submitted', {
has_options: args.options !== undefined && args.options.length >= 2,
});
private async exitWithPlan(
plan: string,
path: string | undefined,
option: ExitPlanModeOption | undefined = undefined,
telemetryResolved = false,
): Promise<ExecutableToolResult> {
const failed = this.exitPlanMode();
if (failed !== undefined) return failed;
if (!telemetryResolved) {
this.agent.telemetry.track('plan_resolved', { outcome: 'auto_approved' });
}
const optionPrefix =
option === undefined
? ''
: `Selected approach: ${option.label}\nExecute ONLY the selected approach. Do not execute any unselected alternatives.\n\n`;
this.agent.telemetry.track('plan_resolved', { outcome: 'auto_approved' });
return {
isError: false,
output: `Exited plan mode. ${optionPrefix}${formatPlanForOutput(plan, path)}`,
output: `Exited plan mode. ${formatPlanForOutput(resolvedPlan.plan, resolvedPlan.path)}`,
};
}
@ -210,32 +209,6 @@ function normalizeOptionLabel(label: string): string {
return label.trim().toLowerCase();
}
function selectedOptionFromMetadata(metadata: unknown): ExitPlanModeOption | undefined {
if (metadata === null || typeof metadata !== 'object') return undefined;
const selectedOption = (metadata as { readonly selectedOption?: unknown }).selectedOption;
if (selectedOption === null || typeof selectedOption !== 'object') return undefined;
const label = (selectedOption as { readonly label?: unknown }).label;
const description = (selectedOption as { readonly description?: unknown }).description;
if (typeof label !== 'string' || typeof description !== 'string') return undefined;
return { label, description };
}
function planTelemetryWasSubmitted(metadata: unknown): boolean {
return (
metadata !== null &&
typeof metadata === 'object' &&
(metadata as { readonly planTelemetrySubmitted?: unknown }).planTelemetrySubmitted === true
);
}
function planTelemetryWasResolved(metadata: unknown): boolean {
return (
metadata !== null &&
typeof metadata === 'object' &&
(metadata as { readonly planTelemetryResolved?: unknown }).planTelemetryResolved === true
);
}
function formatPlanForOutput(plan: string, path: string | undefined): string {
const savedTo = path !== undefined ? `Plan saved to: ${path}\n\n` : '';
return `Plan mode deactivated. All tools are now available.\n${savedTo}## Approved Plan:\n${plan}`;

View file

@ -35,6 +35,7 @@ import type { Environment } from '../../../utils/environment';
import { renderPrompt } from '../../../utils/render-prompt';
import type { BackgroundProcessManager } from '../../background/manager';
import { toInputJsonSchema } from '../../support/input-schema';
import { literalRulePattern, matchesGlobRuleSubject } from '../../support/rule-match';
import { ToolResultBuilder } from '../../support/result-builder';
import bashDescriptionTemplate from './bash.md';
@ -173,6 +174,15 @@ export class BashTool implements BuiltinTool<BashInput> {
description: args.run_in_background
? `Starting background: ${preview}`
: `Running: ${preview}`,
display: {
kind: 'command',
command: args.command,
cwd: args.cwd ?? this.cwd,
description: args.description,
language: 'bash',
},
approvalRule: literalRulePattern(this.name, args.command),
matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.command),
execute: ({ signal }) => this.execution(args, signal),
};
}

View file

@ -102,6 +102,7 @@ export class TodoListTool implements BuiltinTool<TodoListInput> {
: 'Updating todo list';
return {
description,
approvalRule: this.name,
execute: async () => {
// Query mode — return the current list without mutation.
if (args.todos === undefined) {

View file

@ -12,6 +12,7 @@ import type { BuiltinTool } from '../../../agent/tool';
import { ToolAccesses } from '../../../loop/tool-access';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types';
import { toInputJsonSchema } from '../../support/input-schema';
import { literalRulePattern, matchesGlobRuleSubject } from '../../support/rule-match';
import { ToolResultBuilder } from '../../support/result-builder';
import DESCRIPTION from './fetch-url.md';
@ -74,6 +75,9 @@ export class FetchURLTool implements BuiltinTool<FetchURLInput> {
return {
accesses: ToolAccesses.none(),
description: `Fetching: ${preview}`,
display: { kind: 'url_fetch', url: args.url },
approvalRule: literalRulePattern(this.name, args.url),
matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.url),
execute: (ctx) => this.execution(args, ctx),
};
}

View file

@ -12,6 +12,7 @@ import type { BuiltinTool } from '../../../agent/tool';
import { ToolAccesses } from '../../../loop/tool-access';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types';
import { toInputJsonSchema } from '../../support/input-schema';
import { literalRulePattern, matchesGlobRuleSubject } from '../../support/rule-match';
import { ToolResultBuilder } from '../../support/result-builder';
import DESCRIPTION from './web-search.md';
@ -70,6 +71,9 @@ export class WebSearchTool implements BuiltinTool<WebSearchInput> {
return {
accesses: ToolAccesses.none(),
description: `Searching: ${preview}`,
display: { kind: 'search', query: args.query },
approvalRule: literalRulePattern(this.name, args.query),
matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.query),
execute: (ctx) => this.execution(args, ctx),
};
}

View file

@ -1,69 +0,0 @@
/**
* Default permission posture for built-in tools.
*
* This table captures the Python-parity distinction between tools that
* call Approval.request() and tools that run without an approval prompt.
* The rule layer still applies explicit deny rules before consulting this
* table.
*/
export type BuiltinToolDefaultPermission = 'auto_allow' | 'ask';
export type KnownBuiltinToolName =
| 'Read'
| 'Grep'
| 'Glob'
| 'ReadMediaFile'
| 'Think'
| 'TodoList'
| 'TaskList'
| 'TaskOutput'
| 'WebSearch'
| 'FetchURL'
| 'Agent'
| 'AskUserQuestion'
| 'EnterPlanMode'
| 'ExitPlanMode'
| 'Skill'
| 'Bash'
| 'Write'
| 'Edit'
| 'TaskStop';
type BuiltinToolDefaultPermissionTable = Readonly<
Record<KnownBuiltinToolName, BuiltinToolDefaultPermission>
>;
const BUILTIN_TOOL_DEFAULT_PERMISSION_TABLE: BuiltinToolDefaultPermissionTable = {
Read: 'auto_allow',
Grep: 'auto_allow',
Glob: 'auto_allow',
ReadMediaFile: 'auto_allow',
Think: 'auto_allow',
TodoList: 'auto_allow',
TaskList: 'auto_allow',
TaskOutput: 'auto_allow',
WebSearch: 'auto_allow',
FetchURL: 'auto_allow',
Agent: 'auto_allow',
AskUserQuestion: 'auto_allow',
EnterPlanMode: 'auto_allow',
ExitPlanMode: 'auto_allow',
Skill: 'auto_allow',
Bash: 'ask',
Write: 'ask',
Edit: 'ask',
TaskStop: 'ask',
};
export const BUILTIN_TOOL_DEFAULT_PERMISSIONS: BuiltinToolDefaultPermissionTable =
BUILTIN_TOOL_DEFAULT_PERMISSION_TABLE;
export function getBuiltinToolDefaultPermission(
toolName: string,
): BuiltinToolDefaultPermission | undefined {
return BUILTIN_TOOL_DEFAULT_PERMISSIONS[toolName as KnownBuiltinToolName];
}
export function isDefaultAutoAllowTool(toolName: string): boolean {
return getBuiltinToolDefaultPermission(toolName) === 'auto_allow';
}

View file

@ -232,7 +232,7 @@ export function resolvePathAccess(
const outsideWorkspace = !isWithinWorkspace(canonical, config, pathClass);
const policy = options.policy ?? DEFAULT_WORKSPACE_ACCESS_POLICY;
if (policy.checkSensitive && isSensitiveFile(canonical, pathClass)) {
if (policy.checkSensitive && isSensitiveFile(canonical)) {
throw new PathSecurityError(
'PATH_SENSITIVE',
path,

View file

@ -9,8 +9,6 @@
import { basename } from 'pathe';
import type { PathClass } from './path-access';
const SENSITIVE_BASENAMES = new Set<string>([
'.env',
'id_rsa',
@ -43,16 +41,14 @@ export const SENSITIVE_DOT_VARIANT_SUFFIXES = [
] as const;
const SENSITIVE_DOT_VARIANT_SUFFIX_SET = new Set<string>(SENSITIVE_DOT_VARIANT_SUFFIXES);
const DEFAULT_PATH_CLASS: PathClass = process.platform === 'win32' ? 'win32' : 'posix';
function comparable(path: string, pathClass: PathClass): string {
return pathClass === 'win32' ? path.toLowerCase() : path;
function comparable(path: string): string {
return path.toLowerCase();
}
export function isSensitiveFile(path: string, pathClass: PathClass = DEFAULT_PATH_CLASS): boolean {
export function isSensitiveFile(path: string): boolean {
const name = basename(path);
const comparableName = comparable(name, pathClass);
const comparablePath = comparable(path, pathClass);
const comparableName = comparable(name);
const comparablePath = comparable(path);
if (ENV_EXEMPTIONS.has(comparableName)) return false;
if (PUBLIC_KEY_BASENAMES.has(comparableName)) return false;
@ -73,7 +69,7 @@ export function isSensitiveFile(path: string, pathClass: PathClass = DEFAULT_PAT
for (const suffixParts of SENSITIVE_PATH_SUFFIXES) {
const suffix = suffixParts.join('/');
const comparableSuffix = comparable(suffix, pathClass);
const comparableSuffix = comparable(suffix);
if (
comparablePath.endsWith(`/${comparableSuffix}`) ||
comparablePath.includes(`/${comparableSuffix}/`)

View file

@ -2,7 +2,7 @@ import { isAbsolute, join, parse } from 'pathe';
import picomatch from 'picomatch';
import { canonicalizePath, type PathClass } from '../../tools/policies/path-access';
import { canonicalizePath, type PathClass } from '../policies/path-access';
export interface PermissionPathMatchOptions {
readonly cwd?: string;
@ -40,20 +40,15 @@ function stripLeadingDotSlash(value: string): string {
export function pathGlobMatch(
value: string,
pattern: string,
options: {
readonly pathOptions?: PermissionPathMatchOptions;
readonly conservativeCaseFold: boolean;
},
pathOptions?: PermissionPathMatchOptions,
): boolean {
const semantics = pathMatchSemantics(value, pattern, options.pathOptions);
const nocase =
options.pathOptions?.caseInsensitivePaths ??
(semantics.pathClass === 'win32' || options.conservativeCaseFold);
const semantics = pathMatchSemantics(value, pattern, pathOptions);
const nocase = pathOptions?.caseInsensitivePaths ?? true;
if (globMatch(value, pattern, { nocase })) return true;
for (const valueVariant of pathVariants(value, semantics, options.pathOptions)) {
for (const patternVariant of pathVariants(pattern, semantics, options.pathOptions)) {
for (const valueVariant of pathVariants(value, semantics, pathOptions)) {
for (const patternVariant of pathVariants(pattern, semantics, pathOptions)) {
if (globMatch(valueVariant, patternVariant, { nocase })) return true;
}
}

View file

@ -0,0 +1,41 @@
import {
globMatch,
pathGlobMatch,
type PermissionPathMatchOptions,
} from './path-glob-match';
const GLOB_LITERAL_SPECIAL = /[\\*?[\]{}()!+@|]/g;
export function literalRulePattern(toolName: string, subject: string): string {
return `${toolName}(${escapeRuleSubjectLiteral(subject)})`;
}
export function escapeRuleSubjectLiteral(subject: string): string {
return subject.replace(GLOB_LITERAL_SPECIAL, '\\$&');
}
export function matchesGlobRuleSubject(ruleArgs: string, subject: string): boolean {
return matchRuleSubjects(ruleArgs, [subject], (pattern, value) => globMatch(value, pattern));
}
export function matchesPathRuleSubject(
ruleArgs: string,
subject: string,
options?: PermissionPathMatchOptions,
): boolean {
return matchRuleSubjects(ruleArgs, [subject], (pattern, value) =>
pathGlobMatch(value, pattern, options),
);
}
function matchRuleSubjects(
ruleArgs: string,
subjects: readonly string[],
matchesPositivePattern: (pattern: string, subject: string) => boolean,
): boolean {
if (ruleArgs.length === 0) return true;
const negated = ruleArgs.startsWith('!');
const positivePattern = negated ? ruleArgs.slice(1) : ruleArgs;
const hit = subjects.some((subject) => matchesPositivePattern(positivePattern, subject));
return negated ? !hit : hit;
}

View file

@ -96,7 +96,7 @@ it('runs an agent turn through builtin tool approval and execution', async () =>
[emit] assistant.delta { "turnId": 0, "delta": "I will run that." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "argumentsPart": "{\\"command\\":\\"printf lookup-result\\",\\"timeout\\":60}" }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "I will run that." } }, "time": "<time>" }
[emit] requestApproval { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "run command", "display": { "kind": "generic", "summary": "Approve Bash", "detail": { "command": "printf lookup-result", "timeout": 60 } } }
[emit] requestApproval { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf lookup-result", "display": { "kind": "command", "command": "printf lookup-result", "cwd": "<cwd>", "language": "bash" } }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
@ -107,9 +107,9 @@ it('runs an agent turn through builtin tool approval and execution', async () =>
ctx.mockNextResponse({ type: 'text', text: 'The command printed lookup-result.' });
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "run command", "result": { "decision": "approved", "selectedLabel": "approve" }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf lookup-result", "timeout": 60 }, "description": "Running: printf lookup-result" }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf lookup-result", "timeout": 60 }, "description": "Running: printf lookup-result" }
[wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf lookup-result", "result": { "decision": "approved", "selectedLabel": "approve" }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf lookup-result", "timeout": 60 }, "description": "Running: printf lookup-result", "display": { "kind": "command", "command": "printf lookup-result", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf lookup-result", "timeout": 60 }, "description": "Running: printf lookup-result", "display": { "kind": "command", "command": "printf lookup-result", "cwd": "<cwd>", "language": "bash" } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "lookup-result" } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "lookup-result" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "usage": { "inputOther": 11, "output": 22, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }, "time": "<time>" }

View file

@ -117,7 +117,7 @@ describe('Agent config', () => {
[emit] assistant.delta { "turnId": 0, "delta": "I will run Bash." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "argumentsPart": "{\\"command\\":\\"printf original-result\\",\\"timeout\\":60}" }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "I will run Bash." } }, "time": "<time>" }
[emit] requestApproval { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "run command", "display": { "kind": "generic", "summary": "Approve Bash", "detail": { "command": "printf original-result", "timeout": 60 } } }
[emit] requestApproval { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf original-result", "display": { "kind": "command", "command": "printf original-result", "cwd": "<cwd>", "language": "bash" } }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
@ -136,14 +136,14 @@ describe('Agent config', () => {
ctx.mockNextResponse({ type: 'text', text: 'Still using the original turn config.' });
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "run command", "result": { "decision": "approved", "selectedLabel": "approve" }, "time": "<time>" }
[wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf original-result", "result": { "decision": "approved", "selectedLabel": "approve" }, "time": "<time>" }
[wire] config.update { "modelAlias": "changed-model", "time": "<time>" }
[emit] agent.status.updated { "model": "changed-model", "contextTokens": 0, "maxContextTokens": 1000000, "contextUsage": 0, "planMode": false, "permission": "manual" }
[wire] config.update { "systemPrompt": "Changed system prompt.", "time": "<time>" }
[emit] agent.status.updated { "model": "changed-model", "contextTokens": 0, "maxContextTokens": 1000000, "contextUsage": 0, "planMode": false, "permission": "manual" }
[wire] tools.set_active_tools { "names": [], "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf original-result", "timeout": 60 }, "description": "Running: printf original-result" }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf original-result", "timeout": 60 }, "description": "Running: printf original-result" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf original-result", "timeout": 60 }, "description": "Running: printf original-result", "display": { "kind": "command", "command": "printf original-result", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf original-result", "timeout": 60 }, "description": "Running: printf original-result", "display": { "kind": "command", "command": "printf original-result", "cwd": "<cwd>", "language": "bash" } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "original-result" } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "original-result" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "usage": { "inputOther": 9, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }, "time": "<time>" }

View file

@ -270,9 +270,7 @@ function normalizeValue(value: unknown, uuidLabels: Map<string, string>): unknow
.filter(([key]) => !isVolatileDurationKey(key))
.map(([key, nested]) => [
key,
(key === 'time' || key === 'created_at') && typeof nested === 'number'
? '<time>'
: normalizeValue(nested, uuidLabels),
normalizeObjectField(key, nested, uuidLabels),
]),
);
}
@ -280,6 +278,16 @@ function normalizeValue(value: unknown, uuidLabels: Map<string, string>): unknow
return value;
}
function normalizeObjectField(
key: string,
value: unknown,
uuidLabels: Map<string, string>,
): unknown {
if ((key === 'time' || key === 'created_at') && typeof value === 'number') return '<time>';
if (key === 'cwd' && typeof value === 'string') return '<cwd>';
return normalizeValue(value, uuidLabels);
}
function stripUndefined(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => stripUndefined(item));

File diff suppressed because it is too large Load diff

View file

@ -317,7 +317,7 @@ describe('plan exit tool options', () => {
) as { args: { action?: string; display?: { options?: readonly unknown[] } } } | undefined
)?.args;
expect(rpcArgs?.action).toBe('Review plan and choose an option');
expect(rpcArgs?.action).toBe('Presenting plan and exiting plan mode');
expect(rpcArgs?.display?.options).toHaveLength(2);
approval.respond({ decision: 'approved', selectedLabel: 'Approach A' });
@ -446,8 +446,8 @@ describe('plan allows safe tool flow', () => {
[emit] assistant.delta { "turnId": 0, "delta": "I will inspect safely." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "argumentsPart": "{\\"command\\":\\"printf plan-safe\\",\\"timeout\\":60}" }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "I will inspect safely." } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf plan-safe", "timeout": 60 }, "description": "Running: printf plan-safe" }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf plan-safe", "timeout": 60 }, "description": "Running: printf plan-safe" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf plan-safe", "timeout": 60 }, "description": "Running: printf plan-safe", "display": { "kind": "command", "command": "printf plan-safe", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf plan-safe", "timeout": 60 }, "description": "Running: printf plan-safe", "display": { "kind": "command", "command": "printf plan-safe", "cwd": "<cwd>", "language": "bash" } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "plan-safe" } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "plan-safe" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "usage": { "inputOther": 536, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }, "time": "<time>" }
@ -499,8 +499,8 @@ describe('plan mode Bash ordinary permission behavior', () => {
[emit] assistant.delta { "turnId": 0, "delta": "I will mutate a file." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "argumentsPart": "{\\"command\\":\\"rm forbidden.txt\\",\\"timeout\\":60}" }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "I will mutate a file." } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "rm forbidden.txt", "timeout": 60 }, "description": "Running: rm forbidden.txt" }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "rm forbidden.txt", "timeout": 60 }, "description": "Running: rm forbidden.txt" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "rm forbidden.txt", "timeout": 60 }, "description": "Running: rm forbidden.txt", "display": { "kind": "command", "command": "rm forbidden.txt", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "rm forbidden.txt", "timeout": 60 }, "description": "Running: rm forbidden.txt", "display": { "kind": "command", "command": "rm forbidden.txt", "cwd": "<cwd>", "language": "bash" } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "removed" } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "removed" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "usage": { "inputOther": 533, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }, "time": "<time>" }

View file

@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
import {
AGENT_WIRE_PROTOCOL_VERSION,
AgentRecords,
InMemoryAgentRecordPersistence,
type AgentRecord,
} from '../../../src/agent/records';

View file

@ -0,0 +1,38 @@
import type {
WireMigration,
WireMigrationRecord,
} from '../../../../src/agent/records/migration';
import { eventSnapshot } from '../../harness/snapshots';
export function runMigration(
migration: WireMigration,
records: readonly WireMigrationRecord[],
) {
return wireSnapshot(records.map((record) => migrateRecord(migration, record)));
}
function migrateRecord(
migration: WireMigration,
record: WireMigrationRecord,
): WireMigrationRecord {
const migrated = migration.migrateRecord(record);
if (record.type !== 'metadata') return migrated;
return {
...migrated,
protocol_version: migration.targetVersion,
};
}
function wireSnapshot(records: readonly WireMigrationRecord[]) {
return eventSnapshot(
records.map((record) => {
const { type: event, ...args } = record;
return {
type: '[wire]' as const,
event,
args,
};
}),
new Map(),
);
}

View file

@ -1,94 +1,76 @@
import { describe, expect, it } from 'vitest';
import {
AGENT_WIRE_PROTOCOL_VERSION,
AgentRecords,
InMemoryAgentRecordPersistence,
type AgentRecord,
} from '../../../../src/agent/records';
import type { Agent } from '../../../../src/agent';
import { eventSnapshot } from '../../harness/snapshots';
const agent = {
context: { appendMessage() {}, appendLoopEvent() {} },
tools: { registerUserTool() {} },
} as unknown as Agent;
import { migrateV1_0ToV1_1 } from '../../../../src/agent/records/migration/v1.1';
import { runMigration } from './utils';
describe('1.0 to 1.1', () => {
it('rewrites v1.0 records to the v1.1 wire shape', async () => {
const persistence = new InMemoryAgentRecordPersistence([
{
type: 'metadata',
protocol_version: '1.0',
created_at: 1,
},
{
type: 'context.append_message',
message: {
role: 'assistant',
content: [],
toolCalls: [
{
type: 'function',
id: 'call_legacy_bash',
function: {
name: 'Bash',
arguments: '{"command":"pwd"}',
},
},
],
it('rewrites v1.0 records to the v1.1 wire shape', () => {
expect(
runMigration(migrateV1_0ToV1_1, [
{
type: 'metadata',
protocol_version: '1.0',
created_at: 1,
},
} as unknown as AgentRecord,
{
type: 'tools.register_user_tool',
name: 'schema_tool',
description: 'Tool with a schema field named function',
parameters: {
type: 'object',
properties: {
function: {
type: 'object',
properties: {
name: { type: 'string' },
{
type: 'context.append_message',
message: {
role: 'assistant',
content: [],
toolCalls: [
{
type: 'function',
id: 'call_legacy_bash',
function: {
name: 'Bash',
arguments: '{"command":"pwd"}',
},
},
},
value: { type: 'string' },
],
},
required: ['function'],
},
},
{
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'call_payload',
turnId: '0',
step: 1,
stepUuid: 'step_1',
toolCallId: 'call_payload',
name: 'PayloadTool',
args: {
payload: {
type: 'function',
id: 'user_payload',
{
type: 'tools.register_user_tool',
name: 'schema_tool',
description: 'Tool with a schema field named function',
parameters: {
type: 'object',
properties: {
function: {
name: 'do-not-migrate',
arguments: '{"keep":true}',
type: 'object',
properties: {
name: { type: 'string' },
},
},
value: { type: 'string' },
},
required: ['function'],
},
},
{
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'call_payload',
turnId: '0',
step: 1,
stepUuid: 'step_1',
toolCallId: 'call_payload',
name: 'PayloadTool',
args: {
payload: {
type: 'function',
id: 'user_payload',
function: {
name: 'do-not-migrate',
arguments: '{"keep":true}',
},
},
},
},
},
} as unknown as AgentRecord,
]);
const records = new AgentRecords(agent, persistence);
await records.replay();
expect(persistence.records[0]).toMatchObject({
type: 'metadata',
protocol_version: AGENT_WIRE_PROTOCOL_VERSION,
});
expect(wireSnapshot(persistence.records)).toMatchInlineSnapshot(`
]),
).toMatchInlineSnapshot(`
[wire] metadata { "protocol_version": "1.1", "created_at": "<time>" }
[wire] context.append_message { "message": { "role": "assistant", "content": [], "toolCalls": [ { "type": "function", "id": "call_legacy_bash", "name": "Bash", "arguments": "{\\"command\\":\\"pwd\\"}" } ] } }
[wire] tools.register_user_tool { "name": "schema_tool", "description": "Tool with a schema field named function", "parameters": { "type": "object", "properties": { "function": { "type": "object", "properties": { "name": { "type": "string" } } }, "value": { "type": "string" } }, "required": [ "function" ] } }
@ -96,17 +78,3 @@ describe('1.0 to 1.1', () => {
`);
});
});
function wireSnapshot(records: readonly AgentRecord[]) {
return eventSnapshot(
records.map((record) => {
const { type: event, ...args } = record;
return {
type: '[wire]' as const,
event,
args,
};
}),
new Map(),
);
}

View file

@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import { migrateV1_1ToV1_2 } from '../../../../src/agent/records/migration/v1.2';
import { runMigration } from './utils';
describe('1.1 to 1.2', () => {
it('rewrites legacy approve-for-session records with session approval rules', () => {
expect(
runMigration(migrateV1_1ToV1_2, [
{
type: 'metadata',
protocol_version: '1.1',
created_at: 1,
},
{
type: 'permission.record_approval_result',
turnId: 0,
toolCallId: 'call_bash_session',
toolName: 'Bash',
action: 'run command',
result: {
decision: 'approved',
scope: 'session',
selectedLabel: 'Approve for this session',
},
},
{
type: 'permission.record_approval_result',
turnId: 0,
toolCallId: 'call_once',
toolName: 'Bash',
action: 'run command',
result: {
decision: 'approved',
selectedLabel: 'Approve once',
},
},
{
type: 'permission.record_approval_result',
turnId: 0,
toolCallId: 'call_plan_bash',
toolName: 'Bash',
action: 'run command in plan mode',
result: {
decision: 'approved',
scope: 'session',
selectedLabel: 'Approve for this session',
},
},
{
type: 'permission.record_approval_result',
turnId: 0,
toolCallId: 'call_bg_bash',
toolName: 'Bash',
action: 'run background command',
result: {
decision: 'approved',
scope: 'session',
selectedLabel: 'Approve for this session',
},
},
{
type: 'permission.record_approval_result',
turnId: 0,
toolCallId: 'call_kept',
toolName: 'Bash',
action: 'run command',
sessionApprovalRule: 'Bash(printf kept)',
result: {
decision: 'approved',
scope: 'session',
selectedLabel: 'Approve for this session',
},
},
{
type: 'permission.record_approval_result',
turnId: 0,
toolCallId: 'call_custom',
toolName: 'mcp__github__search',
action: 'call MCP tool: github:search',
result: {
decision: 'approved',
scope: 'session',
selectedLabel: 'Approve for this session',
},
},
]),
).toMatchInlineSnapshot(`
[wire] metadata { "protocol_version": "1.2", "created_at": "<time>" }
[wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_bash_session", "toolName": "Bash", "action": "run command", "result": { "decision": "approved", "scope": "session", "selectedLabel": "Approve for this session" }, "sessionApprovalRule": "Bash" }
[wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_once", "toolName": "Bash", "action": "run command", "result": { "decision": "approved", "selectedLabel": "Approve once" } }
[wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_plan_bash", "toolName": "Bash", "action": "run command in plan mode", "result": { "decision": "approved", "scope": "session", "selectedLabel": "Approve for this session" } }
[wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_bg_bash", "toolName": "Bash", "action": "run background command", "result": { "decision": "approved", "scope": "session", "selectedLabel": "Approve for this session" } }
[wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_kept", "toolName": "Bash", "action": "run command", "sessionApprovalRule": "Bash(printf kept)", "result": { "decision": "approved", "scope": "session", "selectedLabel": "Approve for this session" } }
[wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_custom", "toolName": "mcp__github__search", "action": "call MCP tool: github:search", "result": { "decision": "approved", "scope": "session", "selectedLabel": "Approve for this session" }, "sessionApprovalRule": "mcp__github__search" }
`);
});
});

View file

@ -105,14 +105,16 @@ describe('Agent tools', () => {
});
it('continues after a foreground Agent tool returns a max_tokens failure', async () => {
const completion = Promise.reject(
new Error('Subagent turn failed before completing its final summary: reason=max_tokens.'),
);
void completion.catch(() => undefined);
const subagentHost = {
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
resumed: false,
completion: Promise.reject(
new Error('Subagent turn failed before completing its final summary: reason=max_tokens.'),
),
completion,
}),
resume: vi.fn(),
} as unknown as SessionSubagentHost;

View file

@ -102,11 +102,13 @@ describe('Agent turn flow', () => {
},
});
expect(records).toContainEqual({
event: 'hook_triggered',
properties: {
event_type: 'PreToolUse',
action: 'allow',
},
event: 'permission_policy_decision',
properties: expect.objectContaining({
policy_name: 'yolo-mode-approve',
tool_name: 'Bash',
permission_mode: 'yolo',
decision: 'approve',
}),
});
});
@ -253,7 +255,7 @@ describe('Agent turn flow', () => {
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello without login' }] });
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] metadata { "protocol_version": "1.1", "created_at": "<time>" }
[wire] metadata { "protocol_version": "1.2", "created_at": "<time>" }
[wire] turn.prompt { "input": [ { "type": "text", "text": "Hello without login" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Hello without login" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "<time>" }
@ -560,7 +562,7 @@ describe('Agent turn flow', () => {
expect(JSON.stringify(ctx.agent.context.data().history)).not.toContain('late stop hook');
});
it('cancels while waiting for a PreToolUse hook before permission fallback', async () => {
it('cancels while waiting for a PreToolUse hook inside permission evaluation', async () => {
const dir = mkdtempSync(join(tmpdir(), 'kimi-pre-tool-hook-'));
const marker = join(dir, 'started');
const script = [
@ -592,7 +594,7 @@ describe('Agent turn flow', () => {
await ctx.rpc.cancel({ turnId: 0 });
const events = await ctx.untilTurnEnd();
expect(beforeToolCall).not.toHaveBeenCalled();
expect(beforeToolCall).toHaveBeenCalledTimes(1);
expect(execWithEnv).not.toHaveBeenCalled();
expect(events).toContainEqual(
expect.objectContaining({
@ -1235,7 +1237,7 @@ describe('Agent turn flow', () => {
[emit] assistant.delta { "turnId": 0, "delta": "I will run Bash." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "argumentsPart": "{\\"command\\":\\"printf should-not-run\\",\\"timeout\\":60}" }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "I will run Bash." } }, "time": "<time>" }
[emit] requestApproval { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "run command", "display": { "kind": "generic", "summary": "Approve Bash", "detail": { "command": "printf should-not-run", "timeout": 60 } } }
[emit] requestApproval { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
@ -1252,10 +1254,10 @@ describe('Agent turn flow', () => {
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] turn.cancel { "turnId": 0, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf should-not-run", "timeout": 60 } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf should-not-run", "timeout": 60 } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "Tool \\"Bash\\" was aborted during prepareToolExecution hook", "isError": true } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "Tool \\"Bash\\" was aborted during prepareToolExecution hook", "isError": true }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf should-not-run", "timeout": 60 }, "description": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf should-not-run", "timeout": 60 }, "description": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "Tool \\"Bash\\" was aborted", "isError": true } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "Tool \\"Bash\\" was aborted", "isError": true }
[emit] turn.step.interrupted { "turnId": 0, "step": 1, "reason": "aborted" }
[emit] turn.ended { "turnId": 0, "reason": "cancelled" }
`);
@ -1296,7 +1298,7 @@ describe('Agent turn flow', () => {
[emit] assistant.delta { "turnId": 0, "delta": "I will ask first." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "argumentsPart": "{\\"command\\":\\"printf approved\\",\\"timeout\\":60}" }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "I will ask first." } }, "time": "<time>" }
[emit] requestApproval { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "run command", "display": { "kind": "generic", "summary": "Approve Bash", "detail": { "command": "printf approved", "timeout": 60 } } }
[emit] requestApproval { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf approved", "display": { "kind": "command", "command": "printf approved", "cwd": "<cwd>", "language": "bash" } }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
@ -1317,9 +1319,9 @@ describe('Agent turn flow', () => {
});
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "run command", "result": { "decision": "approved", "selectedLabel": "approve" }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf approved", "timeout": 60 }, "description": "Running: printf approved" }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf approved", "timeout": 60 }, "description": "Running: printf approved" }
[wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf approved", "result": { "decision": "approved", "selectedLabel": "approve" }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf approved", "timeout": 60 }, "description": "Running: printf approved", "display": { "kind": "command", "command": "printf approved", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf approved", "timeout": 60 }, "description": "Running: printf approved", "display": { "kind": "command", "command": "printf approved", "cwd": "<cwd>", "language": "bash" } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "approved" } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "approved" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "usage": { "inputOther": 7, "output": 22, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }, "time": "<time>" }
@ -1364,7 +1366,7 @@ describe('Agent turn flow', () => {
[emit] assistant.delta { "turnId": 0, "delta": "I will wait for approval." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "argumentsPart": "{\\"command\\":\\"printf should-not-run\\",\\"timeout\\":60}" }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "I will wait for approval." } }, "time": "<time>" }
[emit] requestApproval { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "run command", "display": { "kind": "generic", "summary": "Approve Bash", "detail": { "command": "printf should-not-run", "timeout": 60 } } }
[emit] requestApproval { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
@ -1381,10 +1383,10 @@ describe('Agent turn flow', () => {
await ctx.rpc.cancel({ turnId: 0 });
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] turn.cancel { "turnId": 0, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf should-not-run", "timeout": 60 } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf should-not-run", "timeout": 60 } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "Tool \\"Bash\\" was aborted during prepareToolExecution hook", "isError": true } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "Tool \\"Bash\\" was aborted during prepareToolExecution hook", "isError": true }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf should-not-run", "timeout": 60 }, "description": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf should-not-run", "timeout": 60 }, "description": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "Tool \\"Bash\\" was aborted", "isError": true } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "Tool \\"Bash\\" was aborted", "isError": true }
[emit] turn.step.interrupted { "turnId": 0, "step": 1, "reason": "aborted" }
[emit] turn.ended { "turnId": 0, "reason": "cancelled" }
`);

View file

@ -13,7 +13,7 @@ import { describe, expect, it } from 'vitest';
import type { LLMChatResponse, LoopHooks } from '../../src/loop/index';
import { makeEndTurnResponse, makeToolCall, makeToolUseResponse } from './fixtures/fake-llm';
import { runTurn } from './fixtures/helpers';
import { EchoTool, GatedTool, markReadAnyFileAccesses, SlowTool } from './fixtures/tools';
import { EchoTool, GatedTool, markReadFileAccesses, SlowTool } from './fixtures/tools';
function waitOneMacrotask(): Promise<void> {
return new Promise((resolve) => {
@ -217,7 +217,7 @@ describe('runTurn — abort handling', () => {
// followed by a tool.result for the same toolCallId. Without this,
// the next turn's messages would carry orphan tool.calls and the
// provider API would reject the conversation.
const slow = markReadAnyFileAccesses(new SlowTool());
const slow = markReadFileAccesses(new SlowTool());
const controller = new AbortController();
const turnPromise = runTurn({

View file

@ -441,6 +441,7 @@ function _typeOnlyChecks(): void {
description: 'x',
parameters: { type: 'object' },
resolveExecution: () => ({
approvalRule: 'x',
execute: async (_ctx: ExecutableToolContext) => ({ output: 'ok' }),
}),
};
@ -452,6 +453,7 @@ function _typeOnlyChecks(): void {
resolveExecution: (args) => ({
accesses: ToolAccesses.none(),
description: `Running ${args.text}`,
approvalRule: 'x',
execute: async (_ctx: ExecutableToolContext) => ({ output: 'ok' }),
}),
};
@ -461,6 +463,7 @@ function _typeOnlyChecks(): void {
description: 'x',
parameters: { type: 'object' },
resolveExecution: () => ({
approvalRule: 'x',
execute: async (_ctx: ExecutableToolContext) => ({ output: 'ok' }),
}),
// @ts-expect-error — there is no second Zod schema source on ExecutableTool.

View file

@ -9,7 +9,7 @@ import type { LoopEvent, LoopLiveEventEmitter } from '../../src/loop/index';
import { CollectingSink } from './fixtures/collecting-sink';
import { makeEndTurnResponse, makeToolCall, makeToolUseResponse } from './fixtures/fake-llm';
import { runTurn } from './fixtures/helpers';
import { EchoTool, markReadAnyFileAccesses, ProgressTool } from './fixtures/tools';
import { EchoTool, markReadFileAccesses, ProgressTool } from './fixtures/tools';
describe('runTurn — LoopEventDispatcher live event containment', () => {
it('contains synchronous emit() throws', async () => {
@ -98,8 +98,8 @@ describe('runTurn — LoopEventDispatcher live event containment', () => {
});
it('emits a documented full event sequence for one tool-bearing turn', async () => {
const echo = markReadAnyFileAccesses(new EchoTool());
const progress = markReadAnyFileAccesses(
const echo = markReadFileAccesses(new EchoTool());
const progress = markReadFileAccesses(
new ProgressTool([{ kind: 'progress', percent: 50 }]),
);
const { sink } = await runTurn({

View file

@ -31,11 +31,11 @@ export interface RecordedToolCall<Input> {
readonly turnId: string;
}
export function markReadAnyFileAccesses<T extends ExecutableTool>(tool: T): T {
export function markReadFileAccesses<T extends ExecutableTool>(tool: T): T {
const resolveExecution = tool.resolveExecution.bind(tool);
tool.resolveExecution = ((input: unknown) => {
const execution = resolveExecution(input);
return { ...execution, accesses: ToolAccesses.readAnyFile() };
tool.resolveExecution = (async (input: unknown) => {
const execution = await resolveExecution(input);
return { ...execution, accesses: ToolAccesses.readFile(`/test/${tool.name}`) };
}) as T['resolveExecution'];
return tool;
}
@ -52,6 +52,7 @@ export class EchoTool implements ExecutableTool<EchoInput> {
resolveExecution(args: EchoInput): ToolExecution {
return {
approvalRule: this.name,
execute: async (ctx): Promise<ExecutableToolResult> => {
this.calls.push({
id: ctx.toolCallId,
@ -79,6 +80,7 @@ export class FailingTool implements ExecutableTool<FailingInput> {
resolveExecution(args: FailingInput): ToolExecution {
return {
approvalRule: this.name,
execute: async (ctx): Promise<ExecutableToolResult> => {
this.calls.push({
id: ctx.toolCallId,
@ -114,6 +116,7 @@ export class SlowTool implements ExecutableTool<SlowInput> {
resolveExecution(args: SlowInput): ToolExecution {
return {
approvalRule: this.name,
execute: async (ctx): Promise<ExecutableToolResult> => {
this.calls.push({
id: ctx.toolCallId,
@ -152,6 +155,7 @@ export class HangingTool implements ExecutableTool<Record<string, unknown>> {
resolveExecution(args: Record<string, unknown>): ToolExecution {
return {
approvalRule: this.name,
execute: async (ctx): Promise<ExecutableToolResult> => {
this.calls.push({
id: ctx.toolCallId,
@ -186,6 +190,7 @@ export class ProgressTool implements ExecutableTool<ProgressInput> {
resolveExecution(args: ProgressInput): ToolExecution {
return {
approvalRule: this.name,
execute: async (ctx): Promise<ExecutableToolResult> => {
this.calls.push({
id: ctx.toolCallId,
@ -211,6 +216,7 @@ export class StrictArgsTool implements ExecutableTool<StrictInput> {
resolveExecution(args: StrictInput): ToolExecution {
return {
approvalRule: this.name,
execute: async (ctx): Promise<ExecutableToolResult> => {
this.calls.push({
id: ctx.toolCallId,
@ -240,6 +246,7 @@ export class ContentBlocksTool implements ExecutableTool<Record<string, unknown>
resolveExecution(args: Record<string, unknown>): ToolExecution {
return {
approvalRule: this.name,
execute: async (ctx): Promise<ExecutableToolResult> => {
this.calls.push({
id: ctx.toolCallId,
@ -284,6 +291,7 @@ export class GatedTool implements ExecutableTool<Record<string, unknown>> {
resolveExecution(args: Record<string, unknown>): ToolExecution {
return {
approvalRule: this.name,
execute: async (ctx): Promise<ExecutableToolResult> => {
this.calls.push({
id: ctx.toolCallId,

View file

@ -28,7 +28,7 @@ import {
EchoTool,
FailingTool,
GatedTool,
markReadAnyFileAccesses,
markReadFileAccesses,
ProgressTool,
StrictArgsTool,
} from './fixtures/tools';
@ -225,6 +225,7 @@ describe('runTurn — tool-call behaviour', () => {
description: 'returns undefined',
parameters: { type: 'object', additionalProperties: true },
resolveExecution: () => ({
approvalRule: 'undef',
execute: async () => undefined as unknown as ExecutableToolResult,
}),
};
@ -249,6 +250,7 @@ describe('runTurn — tool-call behaviour', () => {
description: 'returns {}',
parameters: { type: 'object', additionalProperties: true },
resolveExecution: () => ({
approvalRule: 'noout',
execute: async () => ({}) as ExecutableToolResult,
}),
};
@ -271,6 +273,7 @@ describe('runTurn — tool-call behaviour', () => {
description: 'returns undefined',
parameters: { type: 'object', additionalProperties: true },
resolveExecution: () => ({
approvalRule: 'undef',
execute: async () => undefined as unknown as ExecutableToolResult,
}),
};
@ -406,8 +409,8 @@ describe('runTurn — tool-call behaviour', () => {
// Two GatedTools start, each blocks until released. If the loop executed
// them serially we could not have both `started` promises resolve
// before releasing either.
const a = markReadAnyFileAccesses(new GatedTool('gated-a'));
const b = markReadAnyFileAccesses(new GatedTool('gated-b'));
const a = markReadFileAccesses(new GatedTool('gated-a'));
const b = markReadFileAccesses(new GatedTool('gated-b'));
const turnPromise = runTurn({
tools: [a, b],
@ -676,6 +679,7 @@ class PathLockedGatedTool implements ExecutableTool<PathLockedInput> {
resolveExecution(input: PathLockedInput): ToolExecution {
return {
accesses: ToolAccesses.writeFile(input.path),
approvalRule: this.name,
execute: async (ctx): Promise<ExecutableToolResult> => {
this.calls.push({
id: ctx.toolCallId,

View file

@ -122,27 +122,6 @@ describe('ToolScheduler', () => {
expect(drained).toEqual(['tree-reader', 'child-writer']);
});
it('treats pathless file access as covering the file namespace', async () => {
const started: string[] = [];
const drained: string[] = [];
const scheduler = makeScheduler(drained);
const writer = makeControlledTask('writer', ToolAccesses.writeAnyFile(), started);
const reader = makeControlledTask('reader', readPath('/repo/a.ts'), started);
scheduler.add(writer.task);
scheduler.add(reader.task);
await waitOneMacrotask();
expect(started).toEqual(['writer']);
writer.resolve();
await waitOneMacrotask();
expect(started).toEqual(['writer', 'reader']);
reader.resolve();
await scheduler.collectResults();
expect(drained).toEqual(['writer', 'reader']);
});
it('releases conflicting accesses when a task result rejects', async () => {
const started: string[] = [];
const drained: string[] = [];
@ -216,11 +195,11 @@ describe('ToolScheduler', () => {
expect(drained).toEqual(['writer', 'exclusive', 'reader']);
});
it('serializes all-resource access against read-any-file access', async () => {
it('serializes all-resource access against file access', async () => {
const started: string[] = [];
const drained: string[] = [];
const scheduler = makeScheduler(drained);
const reader = makeControlledTask('reader', ToolAccesses.readAnyFile(), started);
const reader = makeControlledTask('reader', readPath('/repo/a.ts'), started);
const exclusive = makeControlledTask('exclusive', ToolAccesses.all(), started);
scheduler.add(reader.task);

View file

@ -691,6 +691,7 @@ describe('Session resume permission parent chain', () => {
const workDir = join(dir, 'work');
const mainDir = join(sessionDir, 'agents', 'main');
const childDir = join(sessionDir, 'agents', 'agent-0');
const sessionApprovalRule = 'Bash(printf parent)';
await mkdir(workDir, { recursive: true });
await mkdir(sessionDir, { recursive: true });
await writeFile(
@ -731,6 +732,7 @@ describe('Session resume permission parent chain', () => {
toolCallId: 'call_parent_bash',
toolName: 'Bash',
action: 'run command',
sessionApprovalRule,
result: {
decision: 'approved',
scope: 'session',
@ -755,12 +757,8 @@ describe('Session resume permission parent chain', () => {
const child = session.agents.get('agent-0');
expect(child?.permission.mode).toBe('yolo');
expect(child?.permission.rules).toEqual([]);
expect(child?.permission.data().rules).toContainEqual({
decision: 'allow',
scope: 'session-runtime',
pattern: 'Bash',
reason: 'approve_for_session: run command',
});
expect(child?.permission.data().rules).toEqual([]);
expect(child?.permission.sessionApprovalRulePatterns).toContain(sessionApprovalRule);
} finally {
await session.close();
}

View file

@ -1,25 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
getBuiltinToolDefaultPermission,
isDefaultAutoAllowTool,
} from '../../src/tools/policies/default-permissions';
describe('builtin tool default permissions', () => {
it('marks read-only and interaction tools as auto-allow', () => {
for (const toolName of ['Read', 'Glob', 'TodoList', 'AskUserQuestion', 'Agent']) {
expect(isDefaultAutoAllowTool(toolName), toolName).toBe(true);
}
});
it('marks side-effectful tools as ask', () => {
for (const toolName of ['Bash', 'Write', 'Edit', 'TaskStop']) {
expect(getBuiltinToolDefaultPermission(toolName), toolName).toBe('ask');
}
});
it('leaves external and MCP tools unspecified', () => {
expect(getBuiltinToolDefaultPermission('mcp__server__tool')).toBeUndefined();
expect(isDefaultAutoAllowTool('external-tool')).toBe(false);
});
});

View file

@ -148,27 +148,6 @@ describe('ExitPlanMode options schema', () => {
});
describe('ExitPlanMode option output', () => {
it('uses permission metadata to report the selected option', async () => {
const { agent, requestApproval, emit } = makeAgent({
plan: 'plan from file',
});
const result = await execute(
new ExitPlanModeTool(agent),
{ options },
{
selectedOption: options[1],
},
);
expect(requestApproval).not.toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith({ type: 'plan_mode.exit' });
expect(result).toMatchObject({ isError: false });
expect(result.output).toContain('Selected approach: Approach B');
expect(result.output).toContain('Execute ONLY the selected approach');
expect(result.output).toContain('plan from file');
});
it('treats a single option as plain plan approval', async () => {
const { agent, requestApproval, emit } = makeAgent({
plan: 'single option plan',

View file

@ -1,18 +1,24 @@
import type { ExecutableTool, ExecutableToolContext, ToolExecution } from '../../../src/loop';
import type {
ExecutableTool,
ExecutableToolContext,
ExecutableToolResult,
ToolExecution,
} from '../../../src/loop';
import { PathSecurityError } from '../../../src/tools/policies/path-access';
export type TestExecutableToolContext<Input> = ExecutableToolContext & {
readonly args: Input;
};
export function executeTool<Input>(
export async function executeTool<Input>(
tool: ExecutableTool<Input>,
context: TestExecutableToolContext<Input>,
) {
): Promise<ExecutableToolResult> {
const { args, ...executionContext } = context;
let execution: ToolExecution;
try {
execution = tool.resolveExecution(args);
const resolved = tool.resolveExecution(args);
execution = isPromiseLike(resolved) ? await resolved : resolved;
} catch (error) {
const output =
error instanceof PathSecurityError
@ -20,8 +26,12 @@ export function executeTool<Input>(
: `Tool "${tool.name}" failed to resolve execution: ${
error instanceof Error ? error.message : String(error)
}`;
return Promise.resolve({ isError: true, output });
return { isError: true, output };
}
if (execution.isError === true) return Promise.resolve(execution);
if (execution.isError === true) return execution;
return execution.execute(executionContext);
}
function isPromiseLike(value: ToolExecution | Promise<ToolExecution>): value is Promise<ToolExecution> {
return typeof (value as Promise<ToolExecution>).then === 'function';
}

View file

@ -400,11 +400,11 @@ describe('path access policy', () => {
for (const key of keys) {
it(`flags ${key} as sensitive by basename`, () => {
expect(isSensitiveFile(key, 'posix')).toBe(true);
expect(isSensitiveFile(key)).toBe(true);
});
it(`flags /home/user/.ssh/${key} as sensitive`, () => {
expect(isSensitiveFile(`/home/user/.ssh/${key}`, 'posix')).toBe(true);
expect(isSensitiveFile(`/home/user/.ssh/${key}`)).toBe(true);
});
}
});

View file

@ -2,10 +2,14 @@ import type { ToolCall } from '@moonshot-ai/kosong';
import { describe, expect, it, vi } from 'vitest';
import type { Agent } from '../../src/agent';
import { PlanModeGuardPermissionPolicy } from '../../src/agent/permission/policies/plan';
import type { PermissionMode } from '../../src/agent/permission/types';
import type { PermissionPolicyContext } from '../../src/agent/permission/policy';
import type {
PermissionMode,
PermissionPolicyContext,
PermissionPolicyResult,
} from '../../src/agent/permission';
import { PlanModeGuardDenyPermissionPolicy } from '../../src/agent/permission/policies/plan-mode-guard-deny';
import { PlanMode } from '../../src/agent/plan';
import { ToolAccesses } from '../../src/loop';
import type { ToolExecutionHookContext } from '../../src/loop';
const signal = new AbortController().signal;
@ -45,18 +49,28 @@ function hookContext(toolName: string, args: unknown): ToolExecutionHookContext
}
function policyContext(
toolName: string,
args: unknown,
_mode: PermissionMode = 'manual',
accesses = toolAccesses(toolName, args),
): PermissionPolicyContext {
return {
...hookContext(toolName, args),
execution: {
accesses,
approvalRule: toolName,
execute: async () => ({ output: '' }),
},
};
}
function evaluatePlanPolicy(
agent: Agent,
toolName: string,
args: unknown,
mode: PermissionMode = 'manual',
): PermissionPolicyContext {
return {
agent,
mode,
toolCallContext: hookContext(toolName, args),
matchedRule: undefined,
recordApprovalResult: vi.fn(),
};
) {
return new PlanModeGuardDenyPermissionPolicy(agent).evaluate(policyContext(toolName, args, mode));
}
describe('Plan mode permission policy', () => {
@ -65,58 +79,134 @@ describe('Plan mode permission policy', () => {
const planPath = planMode.planFilePath;
if (planPath === null) throw new Error('expected plan path');
expect(evaluatePlanPolicy(agent, 'Write', { path: planPath })).toBeUndefined();
expect(
await PlanModeGuardPermissionPolicy.evaluate(policyContext(agent, 'Write', { path: planPath })),
).toEqual({ kind: 'allow' });
expect(
await PlanModeGuardPermissionPolicy.evaluate(
policyContext(agent, 'Edit', {
evaluatePlanPolicy(
agent,
'Edit',
{
path: planPath,
old_string: 'A',
new_string: 'B',
}),
},
),
).toEqual({ kind: 'allow' });
).toBeUndefined();
});
it('blocks Write and Edit to non-plan files before permission approval', async () => {
const { agent } = await activePlanAgent();
const write = await PlanModeGuardPermissionPolicy.evaluate(
policyContext(agent, 'Write', { path: '/workspace/src/main.ts', content: 'x' }),
);
const edit = await PlanModeGuardPermissionPolicy.evaluate(
policyContext(agent, 'Edit', {
path: '/workspace/src/main.ts',
old_string: 'A',
new_string: 'B',
}),
);
const write = evaluatePlanPolicy(agent, 'Write', {
path: '/workspace/src/main.ts',
content: 'x',
});
const edit = evaluatePlanPolicy(agent, 'Edit', {
path: '/workspace/src/main.ts',
old_string: 'A',
new_string: 'B',
});
expect(write).toMatchObject({ kind: 'result', result: { block: true } });
expect(write?.kind === 'result' ? write.result.reason : '').toContain('current plan file');
expect(write?.kind === 'result' ? write.result.reason : '').toContain('ExitPlanMode');
expect(edit).toMatchObject({ kind: 'result', result: { block: true } });
expect(edit?.kind === 'result' ? edit.result.reason : '').toContain('current plan file');
const writeDeny = expectDeny(write);
expect(writeDeny.message ?? '').toContain('current plan file');
expect(writeDeny.message ?? '').toContain('ExitPlanMode');
const editDeny = expectDeny(edit);
expect(editDeny.message ?? '').toContain('current plan file');
});
it('blocks file edits when plan mode has no selected plan file path', async () => {
const { agent, planMode } = await activePlanAgent();
(planMode as unknown as { _planFilePath: string | null })._planFilePath = null;
const result = await PlanModeGuardPermissionPolicy.evaluate(
policyContext(agent, 'Edit', {
path: '/workspace/src/other.ts',
old_string: 'A',
new_string: 'B',
}),
const result = evaluatePlanPolicy(agent, 'Edit', {
path: '/workspace/src/other.ts',
old_string: 'A',
new_string: 'B',
});
const deny = expectDeny(result);
expect(deny.message ?? '').toContain('(no plan file selected yet)');
expect(deny.message ?? '').toContain('ExitPlanMode');
});
it('blocks file writes when plan mode has no selected plan file path', async () => {
const { agent, planMode } = await activePlanAgent();
(planMode as unknown as { _planFilePath: string | null })._planFilePath = null;
const result = evaluatePlanPolicy(agent, 'Write', {
path: '/workspace/src/other.ts',
content: 'x',
});
const deny = expectDeny(result);
expect(deny.message ?? '').toContain('(no plan file selected yet)');
expect(deny.message ?? '').toContain('ExitPlanMode');
});
it('blocks Write and Edit with no file write access while plan mode is active', async () => {
const { agent } = await activePlanAgent();
const write = new PlanModeGuardDenyPermissionPolicy(agent).evaluate(
policyContext('Write', { content: 'x' }, 'manual', ToolAccesses.none()),
);
const edit = new PlanModeGuardDenyPermissionPolicy(agent).evaluate(
policyContext(
'Edit',
{ old_string: 'A', new_string: 'B' },
'manual',
ToolAccesses.none(),
),
);
expect(result).toMatchObject({ kind: 'result', result: { block: true } });
expect(result?.kind === 'result' ? result.result.reason : '').toContain(
'(no plan file selected yet)',
expectDeny(write);
expectDeny(edit);
});
it('allows multiple writes when every write access targets the active plan file', async () => {
const { agent, planMode } = await activePlanAgent();
const planPath = planMode.planFilePath;
if (planPath === null) throw new Error('expected plan path');
const result = new PlanModeGuardDenyPermissionPolicy(agent).evaluate(
policyContext(
'Write',
{ path: planPath, content: 'x' },
'manual',
[
{ kind: 'file', operation: 'write', path: planPath },
{ kind: 'file', operation: 'readwrite', path: planPath },
],
),
);
expect(result?.kind === 'result' ? result.result.reason : '').toContain('ExitPlanMode');
expect(result).toBeUndefined();
});
it('blocks mixed plan-file and non-plan-file write accesses', async () => {
const { agent, planMode } = await activePlanAgent();
const planPath = planMode.planFilePath;
if (planPath === null) throw new Error('expected plan path');
const result = new PlanModeGuardDenyPermissionPolicy(agent).evaluate(
policyContext(
'Edit',
{ path: planPath, old_string: 'A', new_string: 'B' },
'manual',
[
{ kind: 'file', operation: 'readwrite', path: planPath },
{ kind: 'file', operation: 'write', path: '/workspace/src/main.ts' },
],
),
);
const deny = expectDeny(result);
expect(deny.message ?? '').toContain('current plan file');
});
it('does not block read-only tools while plan mode is active', async () => {
const { agent } = await activePlanAgent();
expect(evaluatePlanPolicy(agent, 'Read', { path: '/workspace/src/main.ts' })).toBeUndefined();
expect(evaluatePlanPolicy(agent, 'Grep', { pattern: 'TODO', path: '/workspace' })).toBeUndefined();
});
it.each(['manual', 'yolo', 'auto'] as const)(
@ -124,16 +214,8 @@ describe('Plan mode permission policy', () => {
async (mode) => {
const { agent } = await activePlanAgent();
expect(
await PlanModeGuardPermissionPolicy.evaluate(
policyContext(agent, 'Bash', { command: 'rm foo.txt' }, mode),
),
).toBeUndefined();
expect(
await PlanModeGuardPermissionPolicy.evaluate(
policyContext(agent, 'Bash', { command: 'ls -la' }, mode),
),
).toBeUndefined();
expect(evaluatePlanPolicy(agent, 'Bash', { command: 'rm foo.txt' }, mode)).toBeUndefined();
expect(evaluatePlanPolicy(agent, 'Bash', { command: 'ls -la' }, mode)).toBeUndefined();
},
);
@ -142,13 +224,16 @@ describe('Plan mode permission policy', () => {
async (mode) => {
const { agent } = await activePlanAgent();
const result = await PlanModeGuardPermissionPolicy.evaluate(
policyContext(agent, 'TaskStop', { task_id: 'bash-abc12345' }, mode),
const result = evaluatePlanPolicy(
agent,
'TaskStop',
{ task_id: 'bash-abc12345' },
mode,
);
expect(result).toMatchObject({ kind: 'result', result: { block: true } });
expect(result?.kind === 'result' ? result.result.reason : '').toContain('plan mode');
expect(result?.kind === 'result' ? result.result.reason : '').toContain('ExitPlanMode');
const deny = expectDeny(result);
expect(deny.message ?? '').toContain('plan mode');
expect(deny.message ?? '').toContain('ExitPlanMode');
},
);
@ -156,20 +241,24 @@ describe('Plan mode permission policy', () => {
const { agent, planMode } = await activePlanAgent();
planMode.exit();
expect(
await PlanModeGuardPermissionPolicy.evaluate(
policyContext(agent, 'Write', { path: '/workspace/src/main.ts' }),
),
).toBeUndefined();
expect(
await PlanModeGuardPermissionPolicy.evaluate(
policyContext(agent, 'Bash', { command: 'rm foo.txt' }),
),
).toBeUndefined();
expect(
await PlanModeGuardPermissionPolicy.evaluate(
policyContext(agent, 'TaskStop', { task_id: 'bash-abc12345' }),
),
).toBeUndefined();
expect(evaluatePlanPolicy(agent, 'Write', { path: '/workspace/src/main.ts' })).toBeUndefined();
expect(evaluatePlanPolicy(agent, 'Bash', { command: 'rm foo.txt' })).toBeUndefined();
expect(evaluatePlanPolicy(agent, 'TaskStop', { task_id: 'bash-abc12345' })).toBeUndefined();
});
});
function toolAccesses(toolName: string, args: unknown) {
const path = args !== null && typeof args === 'object' ? (args as { path?: unknown }).path : undefined;
if (typeof path !== 'string') return ToolAccesses.none();
if (toolName === 'Write') return ToolAccesses.writeFile(path);
if (toolName === 'Edit') return ToolAccesses.readWriteFile(path);
return ToolAccesses.none();
}
function expectDeny(
result: PermissionPolicyResult | undefined,
): Extract<PermissionPolicyResult, { kind: 'deny' }> {
expect(result).toMatchObject({ kind: 'deny' });
if (result?.kind !== 'deny') throw new Error('expected deny result');
return result;
}

View file

@ -5,8 +5,8 @@ import {
PermissionManager,
type ApprovalResponse,
type PermissionMode,
type PermissionPolicyContext,
} from '../../../src/agent/permission';
import type { ToolExecutionHookContext } from '../../../src/loop';
import {
ExitPlanModeTool,
type ExitPlanModeInput,
@ -62,8 +62,10 @@ function makeAgent(input: {
}
async function execute(agent: Agent, args: ExitPlanModeInput = {}) {
const mode = agent.permission.mode;
const manager = new PermissionManager(agent);
manager.mode = agent.permission.mode;
Object.assign(agent, { permission: manager });
manager.mode = mode;
const permissionResult = await manager.beforeToolCall(permissionContext(args));
if (permissionResult?.syntheticResult !== undefined) {
return permissionResult.syntheticResult;
@ -77,12 +79,20 @@ async function execute(agent: Agent, args: ExitPlanModeInput = {}) {
});
}
function permissionContext(args: ExitPlanModeInput): ToolExecutionHookContext {
function permissionContext(args: ExitPlanModeInput): PermissionPolicyContext {
const display: PermissionPolicyContext['execution']['display'] = {
kind: 'plan_review',
plan: '# Plan',
path: '/tmp/kimi-plan.md',
};
if (args.options !== undefined && args.options.length >= 2) {
display.options = args.options;
}
return {
turnId: '7',
stepNumber: 1,
signal: new AbortController().signal,
llm: {} as ToolExecutionHookContext['llm'],
llm: {} as PermissionPolicyContext['llm'],
toolCall: {
id: 'call_exit_plan',
type: 'function',
@ -90,6 +100,12 @@ function permissionContext(args: ExitPlanModeInput): ToolExecutionHookContext {
arguments: JSON.stringify(args),
},
args,
execution: {
description: 'Presenting plan and exiting plan mode',
display,
approvalRule: 'ExitPlanMode',
execute: async () => ({ output: '' }),
},
};
}
@ -124,7 +140,7 @@ describe('ExitPlanMode telemetry', () => {
});
});
it('tracks revision requests with feedback', async () => {
it('handles revision requests with feedback through permission approval telemetry', async () => {
const { agent, telemetryTrack, exitPlanMode } = makeAgent({
mode: 'manual',
approval: {
@ -142,9 +158,16 @@ describe('ExitPlanMode telemetry', () => {
outcome: 'revise',
has_feedback: true,
});
expect(telemetryTrack).toHaveBeenCalledWith(
'permission_approval_result',
expect.objectContaining({
result: 'rejected',
has_feedback: true,
}),
);
});
it('tracks plain rejections without exiting plan mode', async () => {
it('handles plain rejections without exiting plan mode', async () => {
const { agent, telemetryTrack, exitPlanMode } = makeAgent({
mode: 'manual',
approval: { decision: 'rejected' },
@ -157,9 +180,15 @@ describe('ExitPlanMode telemetry', () => {
expect(telemetryTrack).toHaveBeenCalledWith('plan_resolved', {
outcome: 'rejected',
});
expect(telemetryTrack).toHaveBeenCalledWith(
'permission_approval_result',
expect.objectContaining({
result: 'rejected',
}),
);
});
it('tracks dismissed approval dialogs without exiting plan mode', async () => {
it('handles dismissed approval dialogs without exiting plan mode', async () => {
const { agent, telemetryTrack, exitPlanMode } = makeAgent({
mode: 'manual',
approval: { decision: 'cancelled' },
@ -173,9 +202,15 @@ describe('ExitPlanMode telemetry', () => {
expect(telemetryTrack).toHaveBeenCalledWith('plan_resolved', {
outcome: 'dismissed',
});
expect(telemetryTrack).toHaveBeenCalledWith(
'permission_approval_result',
expect.objectContaining({
result: 'cancelled',
}),
);
});
it('tracks reject-and-exit and exits plan mode', async () => {
it('handles reject-and-exit and exits plan mode', async () => {
const { agent, telemetryTrack, exitPlanMode } = makeAgent({
mode: 'manual',
approval: { decision: 'rejected', selectedLabel: 'Reject and Exit' },
@ -189,5 +224,48 @@ describe('ExitPlanMode telemetry', () => {
expect(telemetryTrack).toHaveBeenCalledWith('plan_resolved', {
outcome: 'rejected_and_exited',
});
expect(telemetryTrack).toHaveBeenCalledWith(
'permission_approval_result',
expect.objectContaining({
result: 'rejected',
}),
);
});
it('does not track auto_approved when exitPlanMode fails', async () => {
const { agent, telemetryTrack, exitPlanMode } = makeAgent({ mode: 'auto' });
exitPlanMode.mockImplementation(() => {
throw new Error('state transition failure');
});
const result = await execute(agent);
expect(result.isError).toBe(true);
expect(result.output).toContain('Failed to exit plan mode');
expect(exitPlanMode).toHaveBeenCalledTimes(1);
expect(telemetryTrack).toHaveBeenCalledWith('plan_submitted', { has_options: false });
expect(telemetryTrack).not.toHaveBeenCalledWith('plan_resolved', {
outcome: 'auto_approved',
});
});
it('does not track approved when exitPlanMode fails', async () => {
const { agent, telemetryTrack, exitPlanMode } = makeAgent({
mode: 'manual',
approval: { decision: 'approved' },
});
exitPlanMode.mockImplementation(() => {
throw new Error('state transition failure');
});
const result = await execute(agent);
expect(result.isError).toBe(true);
expect(result.output).toContain('Failed to exit plan mode');
expect(exitPlanMode).toHaveBeenCalledTimes(1);
expect(telemetryTrack).toHaveBeenCalledWith('plan_submitted', { has_options: false });
expect(telemetryTrack).not.toHaveBeenCalledWith('plan_resolved', {
outcome: 'approved',
});
});
});

View file

@ -27,6 +27,19 @@ describe('isSensitiveFile', () => {
}
});
it('matches sensitive patterns case-insensitively on posix paths', () => {
for (const path of [
'.ENV',
'/app/.Env.Local',
'/home/user/.AWS/Credentials',
'/home/user/.GCP/CREDENTIALS',
'/home/user/.ssh/ID_RSA',
'/home/user/.ssh/ID_ED25519.OLD',
]) {
expect(isSensitiveFile(path), path).toBe(true);
}
});
it('does not flag normal source / config files or env exemplars', () => {
// Mirrors the py parametrization exactly. `.envrc`, `environment.py`,
// `.env_example`, `server.key.example`, `id_rsa.pub`, `credentials.json`
@ -44,9 +57,13 @@ describe('isSensitiveFile', () => {
'environment.py',
'.env_example',
'.env.example',
'.ENV.EXAMPLE',
'.env.sample',
'.ENV.SAMPLE',
'.env.template',
'.ENV.TEMPLATE',
'/app/.env.example',
'/app/.ENV.EXAMPLE',
]) {
expect(isSensitiveFile(path), path).toBe(false);
}

View file

@ -115,6 +115,15 @@ describe('ReadTool', () => {
).toBe(false);
});
it('matches permission args with glob path semantics', () => {
const tool = toolWithContent('');
const execution = tool.resolveExecution({ path: '/etc/passwd' });
if (execution.isError === true) throw new TypeError('expected runnable execution');
expect(execution.matchesRule?.('/etc/**')).toBe(true);
expect(execution.matchesRule?.('/var/**')).toBe(false);
});
it('reads text content with stable one-based line numbers', async () => {
const tool = toolWithContent('alpha\nbeta\n');

View file

@ -58,6 +58,21 @@ describe('WriteTool', () => {
expect(params.properties.path.description).toMatch(/absolute/i);
});
it('matches permission args with negated glob path semantics', () => {
const tool = new WriteTool(createFakeKaos(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const insideSrc = tool.resolveExecution({ path: './src/a.ts', content: 'x' });
const outsideSrc = tool.resolveExecution({ path: './README.md', content: 'x' });
if (insideSrc.isError === true || outsideSrc.isError === true) {
throw new TypeError('expected runnable execution');
}
expect(insideSrc.matchesRule?.('!./src/**')).toBe(false);
expect(outsideSrc.matchesRule?.('!./src/**')).toBe(true);
});
it('guides batching large content across multiple write calls', () => {
const tool = new WriteTool(createFakeKaos(), PERMISSIVE_WORKSPACE);

View file

@ -1,4 +1,4 @@
export type TelemetryPrimitive = boolean | number | string | null;
export type TelemetryPrimitive = boolean | number | string | undefined | null;
export type TelemetryProperties = Record<string, TelemetryPrimitive>;
export type TelemetryContext = Record<string, TelemetryPrimitive>;
@ -24,6 +24,7 @@ export interface TelemetryTransport {
export function isTelemetryPrimitive(value: unknown): value is TelemetryPrimitive {
return (
value === null ||
value === undefined ||
typeof value === 'boolean' ||
typeof value === 'string' ||
(typeof value === 'number' && Number.isFinite(value))