09d02c641f
Tested the shell-control guard against the raw command: whitespace normalization collapsed newlines/CR before the guard ran, so 'git status\nrm file.txt' rode a 'git *' allow rule while bash executed both lines. Honored tool-owned allow/prompt policies in yolo mode so per-command prompt rules were no longer silently discarded under the default approvalMode. Added precision regression tests through the real matcher (separators, subshells, redirects, env prefixes, path/quoting variants) that fail on the unfixed head.
246 lines
7.3 KiB
TypeScript
246 lines
7.3 KiB
TypeScript
/**
|
|
* Tool approval resolution.
|
|
*
|
|
* Approval policy is declared by each tool. This module only knows how to:
|
|
* - normalize user `tools.approval.<tool>: allow | deny | prompt` overrides,
|
|
* - compare a tool capability tier against the active approval mode,
|
|
* - format the generic approval prompt body.
|
|
*/
|
|
import type { AgentTool, ToolApprovalDecision, ToolTier } from "@oh-my-pi/pi-agent-core";
|
|
|
|
export type { ToolApproval, ToolApprovalDecision, ToolTier } from "@oh-my-pi/pi-agent-core";
|
|
|
|
export type ApprovalPolicy = "allow" | "deny" | "prompt";
|
|
export type ApprovalMode = "always-ask" | "write" | "yolo";
|
|
|
|
type ApprovalSubject = Pick<AgentTool, "name" | "approval" | "formatApprovalDetails">;
|
|
|
|
export interface ResolvedApproval {
|
|
policy: ApprovalPolicy;
|
|
tier: ToolTier;
|
|
reason?: string;
|
|
override: boolean;
|
|
source?: "tool" | "user" | "mode";
|
|
}
|
|
|
|
const POLICY_VALUES: ReadonlySet<ApprovalPolicy> = new Set(["allow", "deny", "prompt"]);
|
|
const TIER_VALUES: ReadonlySet<ToolTier> = new Set(["read", "write", "exec"]);
|
|
|
|
const TIER_RANK: Record<ToolTier, number> = {
|
|
read: 0,
|
|
write: 1,
|
|
exec: 2,
|
|
};
|
|
|
|
const APPROVAL_MODE_MAX_TIER: Record<ApprovalMode, ToolTier> = {
|
|
"always-ask": "read",
|
|
write: "write",
|
|
yolo: "exec",
|
|
};
|
|
|
|
const DEFAULT_PROMPT_TRUNCATE_CHARS = 2000;
|
|
|
|
/** Best-effort conversion of an arbitrary user-supplied value to a policy. */
|
|
function normalizePolicy(value: unknown): ApprovalPolicy | undefined {
|
|
if (typeof value !== "string") return undefined;
|
|
const lowered = value.trim().toLowerCase();
|
|
return POLICY_VALUES.has(lowered as ApprovalPolicy) ? (lowered as ApprovalPolicy) : undefined;
|
|
}
|
|
|
|
function isToolTier(value: unknown): value is ToolTier {
|
|
return typeof value === "string" && TIER_VALUES.has(value as ToolTier);
|
|
}
|
|
|
|
function normalizeDecision(value: unknown): Omit<ResolvedApproval, "policy"> & { policy?: ApprovalPolicy } {
|
|
if (isToolTier(value)) {
|
|
return { tier: value, override: false };
|
|
}
|
|
|
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
const record = value as Record<string, unknown>;
|
|
const tier = isToolTier(record.tier) ? record.tier : "exec";
|
|
const reason = typeof record.reason === "string" && record.reason.length > 0 ? record.reason : undefined;
|
|
const policy = normalizePolicy(record.policy);
|
|
return {
|
|
tier,
|
|
override: record.override === true,
|
|
...(policy ? { policy } : {}),
|
|
...(reason ? { reason } : {}),
|
|
};
|
|
}
|
|
|
|
return { tier: "exec", override: false };
|
|
}
|
|
|
|
function getToolDecision(
|
|
tool: ApprovalSubject,
|
|
args: unknown,
|
|
): Omit<ResolvedApproval, "policy"> & { policy?: ApprovalPolicy } {
|
|
const approval = tool.approval;
|
|
const decision: ToolApprovalDecision | undefined = typeof approval === "function" ? approval(args) : approval;
|
|
return normalizeDecision(decision);
|
|
}
|
|
|
|
/**
|
|
* Evaluate a tool's own approval declaration against `args` and return the
|
|
* resulting capability tier, defaulting to `exec` when the tool omits an
|
|
* approval. Unlike reading `tool.approval` directly, this runs function-valued
|
|
* approvals — the write tool's `xd://` gate uses it to take a mounted device's
|
|
* argument-dependent tier instead of falling back to `exec`.
|
|
*/
|
|
export function resolveToolTier(tool: ApprovalSubject, args: unknown): ToolTier {
|
|
return getToolDecision(tool, args).tier;
|
|
}
|
|
|
|
function modeApprovesTier(mode: ApprovalMode, tier: ToolTier): boolean {
|
|
return TIER_RANK[tier] <= TIER_RANK[APPROVAL_MODE_MAX_TIER[mode]];
|
|
}
|
|
|
|
/**
|
|
* Resolve approval policy for a tool call.
|
|
*
|
|
* Resolution order:
|
|
* 1. Tool `approval(args)` decision, defaulting to tier "exec" when omitted.
|
|
* 2. User per-tool override, if set and valid.
|
|
* 3. Active mode tier comparison.
|
|
*
|
|
* In yolo mode, override-based tool prompts are ignored; user `tools.approval`
|
|
* settings remain authoritative.
|
|
*/
|
|
export function resolveApproval(
|
|
tool: ApprovalSubject,
|
|
args: unknown,
|
|
mode: ApprovalMode,
|
|
userConfig: Record<string, unknown> = {},
|
|
): ResolvedApproval {
|
|
const decision = getToolDecision(tool, args);
|
|
const userPolicy = Object.hasOwn(userConfig, tool.name) ? normalizePolicy(userConfig[tool.name]) : undefined;
|
|
|
|
if (decision.policy === "deny") {
|
|
return {
|
|
policy: "deny",
|
|
tier: decision.tier,
|
|
override: decision.override,
|
|
source: "tool",
|
|
...(decision.reason ? { reason: decision.reason } : {}),
|
|
};
|
|
}
|
|
if (userPolicy === "deny") {
|
|
return { policy: "deny", tier: decision.tier, override: decision.override, source: "user" };
|
|
}
|
|
|
|
if (mode === "yolo") {
|
|
if (decision.policy) {
|
|
return {
|
|
policy: decision.policy,
|
|
tier: decision.tier,
|
|
override: false,
|
|
source: "tool",
|
|
...(decision.reason ? { reason: decision.reason } : {}),
|
|
};
|
|
}
|
|
return {
|
|
policy: userPolicy ?? "allow",
|
|
tier: decision.tier,
|
|
override: false,
|
|
source: userPolicy ? "user" : "mode",
|
|
};
|
|
}
|
|
|
|
if (decision.override) {
|
|
return {
|
|
policy: decision.policy === "allow" ? "allow" : "prompt",
|
|
tier: decision.tier,
|
|
override: true,
|
|
source: "tool",
|
|
...(decision.reason ? { reason: decision.reason } : {}),
|
|
};
|
|
}
|
|
|
|
if (decision.policy === "allow" || decision.policy === "prompt") {
|
|
return {
|
|
policy: decision.policy,
|
|
tier: decision.tier,
|
|
override: false,
|
|
source: "tool",
|
|
...(decision.reason ? { reason: decision.reason } : {}),
|
|
};
|
|
}
|
|
|
|
if (userPolicy) {
|
|
return { policy: userPolicy, tier: decision.tier, override: false, source: "user" };
|
|
}
|
|
|
|
if (modeApprovesTier(mode, decision.tier)) {
|
|
return { policy: "allow", tier: decision.tier, override: false, source: "mode" };
|
|
}
|
|
|
|
return {
|
|
policy: "prompt",
|
|
tier: decision.tier,
|
|
override: false,
|
|
source: "mode",
|
|
...(decision.reason ? { reason: decision.reason } : {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if a tool call requires user approval.
|
|
*
|
|
* @throws Error if policy is 'deny'
|
|
* @returns Object with required flag and optional reason for the prompt
|
|
*/
|
|
export function requiresApproval(
|
|
tool: ApprovalSubject,
|
|
args: unknown,
|
|
mode: ApprovalMode,
|
|
userConfig: Record<string, unknown> = {},
|
|
): { required: boolean; reason?: string } {
|
|
const { policy, reason, source } = resolveApproval(tool, args, mode, userConfig);
|
|
|
|
if (policy === "deny") {
|
|
if (source === "tool") {
|
|
throw new Error(`Tool "${tool.name}" is blocked by tool policy.${reason ? `\nReason: ${reason}` : ""}`);
|
|
}
|
|
throw new Error(
|
|
`Tool "${tool.name}" is blocked by user policy.\n` +
|
|
`To allow: remove "tools.approval.${tool.name}: deny" from config.`,
|
|
);
|
|
}
|
|
|
|
if (policy === "prompt") return { required: true, reason };
|
|
return { required: false };
|
|
}
|
|
|
|
export function truncateForPrompt(value: string, maxChars = DEFAULT_PROMPT_TRUNCATE_CHARS): string {
|
|
if (value.length <= maxChars) return value;
|
|
const omitted = value.length - maxChars;
|
|
return `${value.slice(0, maxChars)}[…${omitted}ch elided…]`;
|
|
}
|
|
|
|
/**
|
|
* Format the approval prompt body shown to the user.
|
|
*/
|
|
export function formatApprovalPrompt(tool: ApprovalSubject, args: unknown, reason?: string): string {
|
|
const lines = [`Allow tool: ${tool.name}`];
|
|
|
|
if (tool.name.startsWith("mcp__") && tool.approval === undefined) {
|
|
lines.push("Origin: MCP server tool");
|
|
}
|
|
|
|
if (reason) {
|
|
lines.push(`Reason: ${reason}`);
|
|
}
|
|
|
|
const details = tool.formatApprovalDetails?.(args);
|
|
if (typeof details === "string") {
|
|
if (details.length > 0) lines.push(details);
|
|
} else if (Array.isArray(details)) {
|
|
for (const detail of details) {
|
|
if (detail.length > 0) lines.push(detail);
|
|
}
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|