Gate tool calls per command and path, not per tool name
Approval was a list of tool names: `bash` needed it, `read_file` did not. That
fails in a specific way. `bash` covers `git status` and `rm -rf` equally, so a
user working through a batch presses `a` — always allow — on the first prompt and
every later command runs unasked, including the one they would have refused. The
gate was strongest when it mattered least and gone by the time it mattered.
Rules now match the *subject* of a call: the command for `bash`, the path for a
file tool, the pattern for a search.
"permission": {
"bash": { "*": "ask", "git *": "allow", "rm *": "deny" },
"edit_file": { "*": "deny", "src/generated/*": "allow" }
}
Plain last-match-wins, with no special case for deny. An earlier version made
deny win wherever it sat, on the theory that a refusal should be impossible to
undo by accident, and it made default-deny-with-exceptions unexpressible — which
is the shape a careful user actually writes, and the same shape as `*.env` denied
while `*.env.example` is allowed. Refusals that must never be configurable stay
in the guard plugin, which runs ahead of this and which --yolo cannot reach.
Three behaviours fall out of it:
- `always` grants the pattern the tool suggests, not the tool. Approving
`git status` runs `git log` unprompted and still asks about `npm publish`.
- `.env`, `.env.*`, and `.pem` are denied on read by default. Not gated, refused:
a secret that reaches the context is on the wire and in the session file, and
there is no taking it back. `.env.example` stays allowed.
- A call repeated identically three times in one turn asks even when allowed. A
model repeating itself is not making progress, and `bash: allow` is a statement
about which commands are safe rather than permission to loop.
The prompt now says which rule matched and what `always` would grant:
bash wants to run
git status --porcelain
y allow once | a always allow bash git * | n deny
A typo in a decision string is dropped at parse time, leaving the tool on its
default. Treating an unparseable value as `allow` would mean one misspelling
silently removing the gate.
Written after surveying Claude Code, Codex, opencode, and phi. Three of the four
had already moved to per-pattern rules; the credential deny and the repeat guard
come from opencode directly. ROADMAP records what was deliberately not taken and
why — OS sandboxing needs three platform implementations and is worse than
nothing if half-built, and opencode's own docs say LSP integration is often not a
net positive.
581 tests, up from 572. The engine is a pure function tested on its own, and the
loop is tested through a real Session: an allowed pattern never prompts, a denied
one never executes, and a `.env` read leaves no secret in the transcript.
This commit is contained in:
+7
-1
@@ -59,6 +59,7 @@ config: ${configPath()}
|
||||
{ "provider": "openai", "model": "gpt-5", "apiKey": "...",
|
||||
"agent": "default", "thinking": "medium", "plugins": ["guard", "time"],
|
||||
"toolSets": ["edit-plus", "git"], "registryUrl": "https://...",
|
||||
"permission": { "bash": { "*": "ask", "git *": "allow" } },
|
||||
"mcpServers": { "fs": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] } } }
|
||||
|
||||
env: SHIRO_PROVIDER SHIRO_MODEL SHIRO_BASE_URL SHIRO_API_KEY
|
||||
@@ -253,6 +254,7 @@ const session = new Session({
|
||||
plugins,
|
||||
agent: agentVariant,
|
||||
...(cfg.toolSets ? { toolSets: cfg.toolSets } : {}),
|
||||
...(cfg.permission ? { permissions: cfg.permission } : {}),
|
||||
// Headless has no one to answer, so the tool is withheld rather than left to hang.
|
||||
...(headless ? {} : { ask: askBridge.ask }),
|
||||
...(memory ? { memory } : {}),
|
||||
@@ -492,7 +494,11 @@ const header = [
|
||||
memory && memory.all().length > 0 ? `memory: ${memory.all().length} notes about this project` : undefined,
|
||||
mcp && Object.keys(mcp.tools).length > 0 ? `mcp: ${Object.keys(mcp.tools).length} tools` : undefined,
|
||||
...(mcp?.errors ?? []).map((e) => `mcp ${e.server} failed: ${e.message}`),
|
||||
yolo ? 'approvals: OFF (--yolo)' : 'approvals: on for write_file, edit_file, multi_edit, bash, mcp__*',
|
||||
yolo
|
||||
? 'approvals: OFF (--yolo), but deny rules and the guard still apply'
|
||||
: cfg.permission
|
||||
? `approvals: rules for ${Object.keys(cfg.permission).join(', ')}, defaults elsewhere`
|
||||
: 'approvals: ask for write_file, edit_file, multi_edit, bash, mcp__*',
|
||||
cfg.toolSets ? `tool sets: core, ${cfg.toolSets.join(', ')}` : undefined,
|
||||
'/help for commands',
|
||||
]
|
||||
|
||||
@@ -6,6 +6,7 @@ import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { withFallback, type FallbackEvent } from './fallback';
|
||||
import type { McpServerConfig } from './mcp';
|
||||
import { parsePermissions, type PermissionConfig } from './permission';
|
||||
import { isToolSetName, type ToolSetName } from './tools';
|
||||
|
||||
export type ProviderName = 'anthropic' | 'openai';
|
||||
@@ -27,6 +28,8 @@ export type Config = {
|
||||
plugins?: string[];
|
||||
/** Optional tool sets to offer beyond `core`; omit for all of them. */
|
||||
toolSets?: ToolSetName[];
|
||||
/** Which tool calls run, ask, or are refused. Omit for the defaults. */
|
||||
permission?: PermissionConfig;
|
||||
/** Index for `/registry`. Omit for the default one. */
|
||||
registryUrl?: string;
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
@@ -91,6 +94,10 @@ export async function loadConfig(): Promise<Config> {
|
||||
...(file.thinking ? { thinking: file.thinking } : {}),
|
||||
...(Array.isArray(file.plugins) ? { plugins: file.plugins } : {}),
|
||||
...(Array.isArray(file.toolSets) ? { toolSets: file.toolSets.filter(isToolSetName) } : {}),
|
||||
...(() => {
|
||||
const permission = parsePermissions(file.permission);
|
||||
return permission ? { permission } : {};
|
||||
})(),
|
||||
...(typeof file.registryUrl === 'string' ? { registryUrl: file.registryUrl } : {}),
|
||||
...(file.mcpServers ? { mcpServers: file.mcpServers } : {}),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Permission rules: which tool calls run, which ask, which are refused.
|
||||
*
|
||||
* The old model gated by tool name alone, which made `bash` a single yes/no for
|
||||
* both `git status` and `rm -rf`. A user holding `a` through a batch approves the
|
||||
* second along with the first, so the gate stopped meaning anything. Rules match
|
||||
* the tool's *subject* — the command, the path, the pattern — so `git *` can be
|
||||
* allowed while `*` still asks.
|
||||
*
|
||||
* Pure on purpose: no IO, no UI, so precedence and matching are testable without
|
||||
* a terminal or a model.
|
||||
*/
|
||||
|
||||
export type Decision = 'allow' | 'ask' | 'deny';
|
||||
|
||||
/** A rule set for one tool: subject pattern to decision. `*` is the catch-all. */
|
||||
export type ToolRules = Record<string, Decision>;
|
||||
|
||||
/** Either one decision for every call, or per-subject rules. */
|
||||
export type PermissionEntry = Decision | ToolRules;
|
||||
|
||||
export type PermissionConfig = Record<string, PermissionEntry>;
|
||||
|
||||
const DECISIONS = new Set<Decision>(['allow', 'ask', 'deny']);
|
||||
|
||||
export const isDecision = (v: unknown): v is Decision => typeof v === 'string' && DECISIONS.has(v as Decision);
|
||||
|
||||
/**
|
||||
* Glob-ish matching: `*` spans any characters, `?` exactly one.
|
||||
*
|
||||
* Not a regex, deliberately. A rule comes from a config file a human wrote, and
|
||||
* `rm *` should mean what it looks like rather than "rm followed by anything,
|
||||
* where `*` is a quantifier on a space".
|
||||
*/
|
||||
export function matchPattern(pattern: string, subject: string): boolean {
|
||||
if (pattern === '*') return true;
|
||||
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
||||
const source = `^${escaped.replaceAll('*', '[\\s\\S]*').replaceAll('?', '[\\s\\S]')}$`;
|
||||
try {
|
||||
return new RegExp(source).test(subject);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The text a rule for this tool matches against.
|
||||
*
|
||||
* One field per tool, chosen so the rule reads like the thing being gated: a
|
||||
* command for `bash`, a path for anything touching the filesystem, the query for
|
||||
* a search. A tool with no obvious subject matches only `*`, which is why this
|
||||
* returns undefined rather than an empty string — an empty subject would match
|
||||
* a `*` rule and a `?` rule differently for no good reason.
|
||||
*/
|
||||
export function subjectOf(tool: string, input: unknown): string | undefined {
|
||||
if (input === null || typeof input !== 'object') return undefined;
|
||||
const o = input as Record<string, unknown>;
|
||||
|
||||
const str = (key: string) => (typeof o[key] === 'string' ? (o[key] as string) : undefined);
|
||||
|
||||
switch (tool) {
|
||||
case 'bash':
|
||||
return str('command');
|
||||
case 'read_file':
|
||||
case 'write_file':
|
||||
case 'edit_file':
|
||||
case 'multi_edit':
|
||||
case 'list_dir':
|
||||
case 'git_blame':
|
||||
return str('path');
|
||||
case 'read_many_files': {
|
||||
// A batch read is gated on the paths it asks for, so one bad path in
|
||||
// twenty is enough to trigger a rule.
|
||||
const files = o['files'];
|
||||
if (!Array.isArray(files)) return undefined;
|
||||
const paths = files
|
||||
.map((f) => (f && typeof f === 'object' ? (f as { path?: unknown }).path : undefined))
|
||||
.filter((p): p is string => typeof p === 'string');
|
||||
return paths.length > 0 ? paths.join(' ') : undefined;
|
||||
}
|
||||
case 'glob':
|
||||
return str('pattern');
|
||||
case 'grep':
|
||||
return str('pattern');
|
||||
case 'git_diff':
|
||||
case 'git_log':
|
||||
return str('path');
|
||||
case 'git_show':
|
||||
return str('ref');
|
||||
case 'task':
|
||||
return str('description');
|
||||
case 'skill':
|
||||
return str('name');
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A read_many_files subject is several paths at once, so a rule has to match if
|
||||
* it matches any of them: denying `*.env` must catch a batch that includes one.
|
||||
*/
|
||||
const subjectsFor = (tool: string, subject: string): string[] =>
|
||||
tool === 'read_many_files' ? subject.split(' ') : [subject];
|
||||
|
||||
export type Resolved = { decision: Decision; pattern: string | undefined };
|
||||
|
||||
/**
|
||||
* The decision for one call, with the pattern that produced it.
|
||||
*
|
||||
* Later rules win, so a config reads top to bottom: put `*` first and narrow
|
||||
* after it. Object key order is insertion order for string keys, which is what
|
||||
* makes that stable.
|
||||
*
|
||||
* Plain last-match-wins, with no special case for `deny`. An earlier attempt made
|
||||
* deny win outright, on the theory that a deny rule should be impossible to undo
|
||||
* by accident. It made the most useful configuration in the system unexpressible:
|
||||
*
|
||||
* "edit_file": { "*": "deny", "src/generated/*": "allow" }
|
||||
*
|
||||
* Default-deny with narrow allows is what a careful user writes, and a narrow
|
||||
* exception to a broad deny is the same shape as `*.env` denied but
|
||||
* `*.env.example` allowed. Refusals that must never be configurable live in the
|
||||
* guard plugin instead, which runs ahead of this and which `--yolo` cannot reach.
|
||||
*/
|
||||
export function resolve(rules: PermissionEntry | undefined, tool: string, input: unknown): Resolved {
|
||||
if (rules === undefined) return { decision: 'ask', pattern: undefined };
|
||||
if (isDecision(rules)) return { decision: rules, pattern: undefined };
|
||||
|
||||
const subject = subjectOf(tool, input);
|
||||
let hit: Resolved = { decision: 'ask', pattern: undefined };
|
||||
|
||||
for (const [pattern, decision] of Object.entries(rules)) {
|
||||
if (!isDecision(decision)) continue;
|
||||
const matched =
|
||||
pattern === '*' ||
|
||||
(subject !== undefined && subjectsFor(tool, subject).some((s) => matchPattern(pattern, s)));
|
||||
if (matched) hit = { decision, pattern };
|
||||
}
|
||||
|
||||
return hit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defaults, applied when the config says nothing about a tool.
|
||||
*
|
||||
* Read-only tools run; anything that writes or executes asks. `.env` is denied on
|
||||
* read because a model that greps for a config value will find a credential, and
|
||||
* "it was in the context" is not recoverable.
|
||||
*/
|
||||
export const DEFAULT_PERMISSIONS: PermissionConfig = {
|
||||
read_file: { '*': 'allow', '*.env': 'deny', '*.env.*': 'deny', '*.env.example': 'allow', '*.pem': 'deny' },
|
||||
read_many_files: { '*': 'allow', '*.env': 'deny', '*.env.*': 'deny', '*.env.example': 'allow', '*.pem': 'deny' },
|
||||
write_file: 'ask',
|
||||
edit_file: 'ask',
|
||||
multi_edit: 'ask',
|
||||
bash: 'ask',
|
||||
};
|
||||
|
||||
/** Session, plugin, and read-only tools that never gate. */
|
||||
const FREE = new Set([
|
||||
'glob',
|
||||
'grep',
|
||||
'list_dir',
|
||||
'task',
|
||||
'todo_write',
|
||||
'remember',
|
||||
'recall',
|
||||
'forget',
|
||||
'skill',
|
||||
'ask',
|
||||
'current_time',
|
||||
'git_status',
|
||||
'git_diff',
|
||||
'git_log',
|
||||
'git_show',
|
||||
'git_blame',
|
||||
]);
|
||||
|
||||
export type PermissionOptions = {
|
||||
config?: PermissionConfig;
|
||||
/** --yolo: fold `ask` into `allow`. Never touches `deny`. */
|
||||
yolo?: boolean;
|
||||
/** Names that never prompt whatever the rules say, e.g. the subagent tool. */
|
||||
autoApprove?: readonly string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Rules for a tool, config over defaults.
|
||||
*
|
||||
* Merged per tool rather than per pattern: a config that says anything about
|
||||
* `bash` replaces the default for `bash` entirely. Merging pattern-by-pattern
|
||||
* would leave a user unable to remove a default deny rule, which is the kind of
|
||||
* surprise that ends with someone disabling the whole system.
|
||||
*/
|
||||
function entryFor(tool: string, config: PermissionConfig | undefined): PermissionEntry | undefined {
|
||||
if (config && tool in config) return config[tool];
|
||||
if (config && !(tool in config)) {
|
||||
for (const [pattern, entry] of Object.entries(config)) {
|
||||
if (pattern.includes('*') && matchPattern(pattern, tool)) return entry;
|
||||
}
|
||||
}
|
||||
if (tool in DEFAULT_PERMISSIONS) return DEFAULT_PERMISSIONS[tool];
|
||||
if (FREE.has(tool)) return 'allow';
|
||||
// Unknown tool, which in practice means MCP or a plugin: ask.
|
||||
return 'ask';
|
||||
}
|
||||
|
||||
export class Permissions {
|
||||
private readonly config: PermissionConfig | undefined;
|
||||
private readonly yolo: boolean;
|
||||
private readonly autoApprove: Set<string>;
|
||||
/** Patterns approved with `always` for the rest of this session. */
|
||||
private readonly granted = new Map<string, Set<string>>();
|
||||
|
||||
constructor(opts: PermissionOptions = {}) {
|
||||
this.config = opts.config;
|
||||
this.yolo = opts.yolo ?? false;
|
||||
this.autoApprove = new Set(opts.autoApprove ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a session-wide `always` would whitelist.
|
||||
*
|
||||
* A bash command becomes its first word plus `*`, so approving `git status`
|
||||
* approves `git *` and not every command ever. Anything else falls back to the
|
||||
* tool's own catch-all, since a path pattern guessed from one path is more
|
||||
* likely to be wrong than useful.
|
||||
*/
|
||||
suggest(tool: string, input: unknown): string {
|
||||
if (tool !== 'bash') return '*';
|
||||
const command = subjectOf(tool, input);
|
||||
if (!command) return '*';
|
||||
const head = command.trim().split(/\s+/)[0];
|
||||
return head ? `${head} *` : '*';
|
||||
}
|
||||
|
||||
/** Records an `always` decision as a pattern rather than a bare tool name. */
|
||||
grant(tool: string, pattern: string): void {
|
||||
const set = this.granted.get(tool) ?? new Set<string>();
|
||||
set.add(pattern);
|
||||
this.granted.set(tool, set);
|
||||
}
|
||||
|
||||
granted_(tool: string): string[] {
|
||||
return [...(this.granted.get(tool) ?? [])];
|
||||
}
|
||||
|
||||
/** The decision for one call, and which pattern decided it. */
|
||||
check(tool: string, input: unknown): Resolved {
|
||||
const resolved = resolve(entryFor(tool, this.config), tool, input);
|
||||
if (resolved.decision === 'deny') return resolved;
|
||||
|
||||
if (this.autoApprove.has(tool)) return { decision: 'allow', pattern: undefined };
|
||||
|
||||
const subject = subjectOf(tool, input);
|
||||
for (const pattern of this.granted.get(tool) ?? []) {
|
||||
if (pattern === '*' || (subject !== undefined && matchPattern(pattern, subject))) {
|
||||
return { decision: 'allow', pattern };
|
||||
}
|
||||
}
|
||||
|
||||
if (resolved.decision === 'ask' && this.yolo) return { decision: 'allow', pattern: resolved.pattern };
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a `permission` config block, dropping anything malformed.
|
||||
*
|
||||
* A typo must not silently widen access. An unrecognised decision string is
|
||||
* dropped, which leaves the pattern unmatched and the tool on its default —
|
||||
* `ask` for anything that writes.
|
||||
*/
|
||||
export function parsePermissions(raw: unknown): PermissionConfig | undefined {
|
||||
if (!raw || typeof raw !== 'object') return undefined;
|
||||
const out: PermissionConfig = {};
|
||||
|
||||
for (const [tool, entry] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (isDecision(entry)) {
|
||||
out[tool] = entry;
|
||||
continue;
|
||||
}
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
|
||||
const rules: ToolRules = {};
|
||||
for (const [pattern, decision] of Object.entries(entry as Record<string, unknown>)) {
|
||||
if (isDecision(decision)) rules[pattern] = decision;
|
||||
}
|
||||
if (Object.keys(rules).length > 0) out[tool] = rules;
|
||||
}
|
||||
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
export { FREE as FREE_TOOLS };
|
||||
+92
-32
@@ -12,25 +12,26 @@ import { createAskTool, type AskFn } from './ask';
|
||||
import type { Instructions } from './instructions';
|
||||
import type { Memory } from './memory';
|
||||
import { Notebook, type NotebookState } from './notebook';
|
||||
import { Permissions, type PermissionConfig } from './permission';
|
||||
import type { PluginHost } from './plugins';
|
||||
import { systemPrompt } from './prompt';
|
||||
import { prunePreservingItems } from './prune';
|
||||
import { createSkillTool, renderSkills, type Skill } from './skills';
|
||||
import {
|
||||
MUTATING_TOOLS,
|
||||
disabledToolNames,
|
||||
onBashOutput,
|
||||
tools as builtinTools,
|
||||
type ToolSetName,
|
||||
} from './tools';
|
||||
import { disabledToolNames, onBashOutput, tools as builtinTools, type ToolSetName } from './tools';
|
||||
|
||||
export type ApprovalRequest = {
|
||||
approvalId: string;
|
||||
toolName: string;
|
||||
input: unknown;
|
||||
/** The rule that decided this needs asking, when one did. */
|
||||
matchedPattern?: string;
|
||||
/** What `always` would whitelist, e.g. `git *` rather than every bash call. */
|
||||
suggestedPattern: string;
|
||||
/** Set when the call is being asked about because it repeated, not because of a rule. */
|
||||
repeated?: boolean;
|
||||
};
|
||||
|
||||
/** 'once' runs this call only; 'always' whitelists the tool for the rest of the session. */
|
||||
/** 'once' runs this call only; 'always' whitelists the suggested pattern for the session. */
|
||||
export type ApprovalDecision = 'once' | 'always' | 'deny';
|
||||
|
||||
export type AgentEvent =
|
||||
@@ -57,6 +58,8 @@ export type SessionOptions = {
|
||||
extraTools?: ToolSet;
|
||||
/** Tool sets offered this session; omit for all of them. `core` is always on. */
|
||||
toolSets?: readonly ToolSetName[];
|
||||
/** Rules deciding which calls run, ask, or are refused. Omit for the defaults. */
|
||||
permissions?: PermissionConfig;
|
||||
/** Tool names that never prompt, e.g. the read-only subagent tool. */
|
||||
autoApprove?: readonly string[];
|
||||
/** Prune the history once the estimated token count crosses this. */
|
||||
@@ -86,6 +89,13 @@ const estimateTokens = (messages: ModelMessage[]) => Math.round(JSON.stringify(m
|
||||
/** Estimated tokens at which the wire history is pruned. */
|
||||
const DEFAULT_COMPACT_THRESHOLD = 120_000;
|
||||
|
||||
/** Identical calls in one turn before an allowed tool is asked about anyway. */
|
||||
const REPEAT_LIMIT = 3;
|
||||
|
||||
const callKey = (toolName: string, input: unknown) => `${toolName}:${JSON.stringify(input ?? null)}`;
|
||||
|
||||
type ApprovalContext = Pick<ApprovalRequest, 'matchedPattern' | 'suggestedPattern' | 'repeated'>;
|
||||
|
||||
export class Session {
|
||||
readonly messages: ModelMessage[];
|
||||
readonly tools: ToolSet;
|
||||
@@ -94,7 +104,9 @@ export class Session {
|
||||
outputTokens = 0;
|
||||
private model: LanguageModel;
|
||||
private variant: AgentVariant;
|
||||
private readonly alwaysAllow = new Set<string>();
|
||||
private readonly permissions: Permissions;
|
||||
/** Calls seen this turn, for the repeat guard. Cleared per turn, not per step. */
|
||||
private readonly seen = new Map<string, number>();
|
||||
private controller: AbortController | undefined;
|
||||
|
||||
constructor(private readonly opts: SessionOptions) {
|
||||
@@ -112,13 +124,16 @@ export class Session {
|
||||
};
|
||||
this.tools = { ...builtinTools, ...sessionTools, ...(opts.plugins?.tools ?? {}), ...(opts.extraTools ?? {}) };
|
||||
|
||||
for (const name of [
|
||||
...(opts.autoApprove ?? []),
|
||||
...(opts.plugins?.autoApprove ?? []),
|
||||
...Object.keys(sessionTools),
|
||||
]) {
|
||||
this.alwaysAllow.add(name);
|
||||
}
|
||||
this.permissions = new Permissions({
|
||||
...(opts.permissions ? { config: opts.permissions } : {}),
|
||||
...(opts.yolo ? { yolo: true } : {}),
|
||||
autoApprove: [
|
||||
...(opts.autoApprove ?? []),
|
||||
...(opts.plugins?.autoApprove ?? []),
|
||||
// A session tool touches the agent's own state, not the workspace.
|
||||
...Object.keys(sessionTools),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
setModel(model: LanguageModel): void {
|
||||
@@ -186,32 +201,67 @@ export class Session {
|
||||
});
|
||||
}
|
||||
|
||||
/** Tools that mutate the workspace, plus every externally provided MCP tool. */
|
||||
private needsApproval(name: string): boolean {
|
||||
return (MUTATING_TOOLS as readonly string[]).includes(name) || name.startsWith('mcp__');
|
||||
/**
|
||||
* How many times this exact call has already been made this turn.
|
||||
*
|
||||
* A model that repeats an identical call is not making progress: either it is
|
||||
* ignoring the result or the result is not what it needed. Three is the point
|
||||
* where that stops looking like a coincidence.
|
||||
*/
|
||||
private repeatCount(toolName: string, input: unknown): number {
|
||||
const key = callKey(toolName, input);
|
||||
const count = (this.seen.get(key) ?? 0) + 1;
|
||||
this.seen.set(key, count);
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval decisions, evaluated per call by the SDK.
|
||||
*
|
||||
* A plugin guard denies outright and is checked before anything else, so `--yolo`
|
||||
* cannot bypass it. Only after the guard passes does yolo or the mutating-tool
|
||||
* rule decide whether the user is asked.
|
||||
* Order matters, and each step exists for a different reason:
|
||||
*
|
||||
* 1. A plugin guard refuses outright. `--yolo` cannot reach it, because a
|
||||
* refusal is a policy decision rather than a permission question.
|
||||
* 2. Permission rules decide allow / ask / deny, matched against the call's
|
||||
* subject — the command, the path — not just the tool name.
|
||||
* 3. An allowed call that has now repeated three times identically is asked
|
||||
* about anyway. A rule saying `bash: allow` is a statement about which
|
||||
* commands are safe, not permission to run one in a loop forever.
|
||||
*
|
||||
* `why` collects what the UI needs to explain the prompt, keyed by call, because
|
||||
* the SDK's own approval request carries only the tool name and input.
|
||||
*/
|
||||
private toolApproval(notices: string[]) {
|
||||
private toolApproval(notices: string[], why: Map<string, ApprovalContext>) {
|
||||
return async ({ toolCall }: { toolCall: { toolName: string; input: unknown } }) => {
|
||||
const { toolName, input } = toolCall;
|
||||
|
||||
const blocked = await this.opts.plugins?.guard({
|
||||
toolName: toolCall.toolName,
|
||||
input: toolCall.input,
|
||||
toolName,
|
||||
input,
|
||||
cwd: this.opts.cwd ?? process.cwd(),
|
||||
});
|
||||
if (blocked) {
|
||||
notices.push(blocked);
|
||||
return { type: 'denied' as const, reason: blocked };
|
||||
}
|
||||
if (this.opts.yolo) return undefined;
|
||||
if (!this.needsApproval(toolCall.toolName)) return undefined;
|
||||
if (this.alwaysAllow.has(toolCall.toolName)) return undefined;
|
||||
|
||||
const { decision, pattern } = this.permissions.check(toolName, input);
|
||||
if (decision === 'deny') {
|
||||
const reason = pattern
|
||||
? `Refused by the permission rule ${toolName}: "${pattern}" = deny.`
|
||||
: `Refused by the permission rules: ${toolName} is denied.`;
|
||||
notices.push(reason);
|
||||
return { type: 'denied' as const, reason };
|
||||
}
|
||||
|
||||
const repeats = this.repeatCount(toolName, input);
|
||||
if (decision === 'allow' && repeats < REPEAT_LIMIT) return undefined;
|
||||
|
||||
why.set(callKey(toolName, input), {
|
||||
...(pattern ? { matchedPattern: pattern } : {}),
|
||||
suggestedPattern: this.permissions.suggest(toolName, input),
|
||||
...(decision === 'allow' ? { repeated: true } : {}),
|
||||
});
|
||||
return 'user-approval' as const;
|
||||
};
|
||||
}
|
||||
@@ -243,6 +293,9 @@ export class Session {
|
||||
this.controller = new AbortController();
|
||||
const signal = this.controller.signal;
|
||||
const threshold = this.compactThreshold();
|
||||
// Per turn, not per step: a tool called once in each of three steps is the
|
||||
// loop this guards against.
|
||||
this.seen.clear();
|
||||
|
||||
const outputs: Extract<AgentEvent, { type: 'tool-output' }>[] = [];
|
||||
onBashOutput(({ toolCallId, chunk }) => {
|
||||
@@ -269,6 +322,7 @@ export class Session {
|
||||
const pending: ApprovalRequest[] = [];
|
||||
const compactions: Extract<AgentEvent, { type: 'compacted' }>[] = [];
|
||||
const guardNotices: string[] = [];
|
||||
const why = new Map<string, ApprovalContext>();
|
||||
let sawError = false;
|
||||
|
||||
const result = streamText({
|
||||
@@ -278,7 +332,7 @@ export class Session {
|
||||
tools: this.tools,
|
||||
activeTools: this.activeTools(),
|
||||
reasoning: sdkReasoning(this.variant.thinking),
|
||||
toolApproval: this.toolApproval(guardNotices),
|
||||
toolApproval: this.toolApproval(guardNotices, why),
|
||||
stopWhen: isStepCount(this.variant.maxSteps ?? this.opts.maxSteps ?? 50),
|
||||
maxRetries: this.opts.maxRetries ?? 3,
|
||||
abortSignal: signal,
|
||||
@@ -336,16 +390,20 @@ export class Session {
|
||||
case 'tool-error':
|
||||
yield { type: 'tool-error', id: part.toolCallId, name: part.toolName, error: part.error };
|
||||
break;
|
||||
case 'tool-approval-request':
|
||||
case 'tool-approval-request': {
|
||||
// A guard denial is answered by the SDK itself and arrives flagged
|
||||
// automatic; queueing it would prompt the user for a settled call.
|
||||
if (part.isAutomatic) break;
|
||||
const context = why.get(callKey(part.toolCall.toolName, part.toolCall.input));
|
||||
pending.push({
|
||||
approvalId: part.approvalId,
|
||||
toolName: part.toolCall.toolName,
|
||||
input: part.toolCall.input,
|
||||
suggestedPattern: '*',
|
||||
...context,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'tool-approval-response':
|
||||
if (!part.approved) yield { type: 'tool-denied', name: part.toolCall.toolName };
|
||||
break;
|
||||
@@ -393,8 +451,10 @@ export class Session {
|
||||
|
||||
const responses: ToolApprovalResponse[] = [];
|
||||
for (const req of pending) {
|
||||
const decision = this.alwaysAllow.has(req.toolName) ? 'always' : await this.opts.askApproval(req);
|
||||
if (decision === 'always') this.alwaysAllow.add(req.toolName);
|
||||
const decision = await this.opts.askApproval(req);
|
||||
// `always` records the pattern the tool suggested, so approving
|
||||
// `git status` whitelists `git *` rather than every command.
|
||||
if (decision === 'always') this.permissions.grant(req.toolName, req.suggestedPattern);
|
||||
responses.push({
|
||||
type: 'tool-approval-response',
|
||||
approvalId: req.approvalId,
|
||||
|
||||
+16
-3
@@ -195,12 +195,25 @@ function Approval({ pending }: { pending: Pending }) {
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
|
||||
<Text color="yellow" bold>
|
||||
{pending.req.toolName} wants to run
|
||||
{pending.req.repeated
|
||||
? `${pending.req.toolName} is repeating the same call`
|
||||
: `${pending.req.toolName} wants to run`}
|
||||
</Text>
|
||||
{pending.req.repeated && (
|
||||
<Text dimColor>
|
||||
allowed by the rules, but this is the third identical call this turn
|
||||
</Text>
|
||||
)}
|
||||
{!pending.req.repeated && pending.req.matchedPattern && pending.req.matchedPattern !== '*' && (
|
||||
<Text dimColor>{`matched ${pending.req.toolName}: "${pending.req.matchedPattern}"`}</Text>
|
||||
)}
|
||||
<ApprovalDetail name={pending.req.toolName} input={pending.req.input} />
|
||||
<Text>
|
||||
<Text color="green">y</Text> allow once | <Text color="green">a</Text> always allow {pending.req.toolName} |{' '}
|
||||
<Text color="red">n</Text> deny
|
||||
<Text color="green">y</Text> allow once | <Text color="green">a</Text> always allow{' '}
|
||||
{pending.req.suggestedPattern === '*'
|
||||
? pending.req.toolName
|
||||
: `${pending.req.toolName} ${pending.req.suggestedPattern}`}{' '}
|
||||
| <Text color="red">n</Text> deny
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user