Loop and ergonomics batch across Now/Next and Maintenance:
- /undo and /redo via pre-prompt file snapshots (snapshot.ts)
- task takes a tasks[] array and runs investigations concurrently (subagent.ts)
- lazy MCP tools: mcp_list/mcp_inspect/mcp_call meta-tools, eager opt-in (mcp.ts, config.ts)
- skill tool reads its list live so a mid-session install is callable next turn (skills.ts)
- tool-name lists (tool-kinds.ts) derived from a mutating() marker; gates previously ungated writes
- prune/session recovery path summarized, and step-back doom-loop primitive (step-back.ts)
- @file completion re-walks on a slow cooldown; estimateTokens and pricing labeled as estimates
Docs: README, CHANGELOG, docs/{mcp,architecture,development} updated to match.
CI/CD: bun install-store cache and concurrency gates on both workflows; release.yml now
composes file-based release notes via scripts/make-release-notes.ts and verifies every binary.
328 lines
12 KiB
TypeScript
328 lines
12 KiB
TypeScript
/**
|
|
* 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 'delete_file':
|
|
case 'list_dir':
|
|
case 'git_blame':
|
|
return str('path');
|
|
case 'move_file': {
|
|
// Both ends matter: a rule denying `src/generated/*` must catch a move that
|
|
// lands there as well as one that starts there.
|
|
const from = str('from');
|
|
const to = str('to');
|
|
const both = [from, to].filter((p): p is string => p !== undefined);
|
|
return both.length > 0 ? both.join(' ') : undefined;
|
|
}
|
|
case 'web_fetch':
|
|
return str('url');
|
|
case 'apply_patch': {
|
|
// Every path the patch touches, so denying `src/generated/*` catches a patch
|
|
// that includes one alongside files it may edit.
|
|
const patch = str('patch');
|
|
if (!patch) return undefined;
|
|
const paths = [...patch.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)].map((m) => m[1]!.trim());
|
|
const moves = [...patch.matchAll(/^\*\*\* Move to: (.+)$/gm)].map((m) => m[1]!.trim());
|
|
const all = [...paths, ...moves];
|
|
return all.length > 0 ? all.join(' ') : undefined;
|
|
}
|
|
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 subject that is several strings at once is matched if a rule matches any of
|
|
* them: denying `*.env` must catch a batch read that includes one, and denying
|
|
* `src/generated/*` must catch a patch that touches one among five files.
|
|
*/
|
|
const MULTI = new Set(['read_many_files', 'apply_patch', 'move_file']);
|
|
|
|
const subjectsFor = (tool: string, subject: string): string[] =>
|
|
MULTI.has(tool) ? 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',
|
|
apply_patch: 'ask',
|
|
move_file: 'ask',
|
|
delete_file: 'ask',
|
|
insert_lines: 'ask',
|
|
delete_lines: 'ask',
|
|
replace_lines: 'ask',
|
|
append_file: 'ask',
|
|
prepend_file: 'ask',
|
|
bash: 'ask',
|
|
web_fetch: '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',
|
|
'git_branch',
|
|
'git_commit_message',
|
|
]);
|
|
|
|
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);
|
|
}
|
|
|
|
/** 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 };
|