feat(agent): wire exec approval callback into tool execution pipeline
- Add optional onApprovalNeeded callback to exec tool (backward compatible) - Thread callback through CreateToolsOptions → AgentOptions → resolveTools - Add ExecApprovalConfig to ProfileConfig for per-profile configuration - Create CLI terminal approval callback (readline-based) for non-Hub mode - Export all exec approval types and functions from tools index Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e67682cfa0
commit
89089ef866
6 changed files with 239 additions and 2 deletions
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
import type { ToolsConfig } from "../tools/policy.js";
|
||||
import type { ExecApprovalConfig } from "../tools/exec-approval-types.js";
|
||||
|
||||
/** Profile filename constants */
|
||||
export const PROFILE_FILES = {
|
||||
|
|
@ -39,6 +40,8 @@ export interface ProfileConfig {
|
|||
thinkingLevel?: string;
|
||||
/** Reasoning mode: off, on, stream */
|
||||
reasoningMode?: "off" | "on" | "stream" | undefined;
|
||||
/** Exec approval configuration (security level, ask mode, allowlist) */
|
||||
execApproval?: ExecApprovalConfig | undefined;
|
||||
}
|
||||
|
||||
/** Agent Profile configuration */
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { createMemoryTools } from "./tools/memory/index.js";
|
|||
import { createSessionsSpawnTool } from "./tools/sessions-spawn.js";
|
||||
import { filterTools } from "./tools/policy.js";
|
||||
import { isMulticaError, isRetryableError } from "../shared/errors.js";
|
||||
import type { ExecApprovalCallback } from "./tools/exec-approval-types.js";
|
||||
|
||||
// Re-export resolveModel from providers for backwards compatibility
|
||||
export { resolveModel } from "./providers/index.js";
|
||||
|
|
@ -25,6 +26,8 @@ export interface CreateToolsOptions {
|
|||
isSubagent?: boolean | undefined;
|
||||
/** Session ID of the agent (passed to sessions_spawn tool) */
|
||||
sessionId?: string | undefined;
|
||||
/** Callback invoked when exec tool needs approval before running a command */
|
||||
onExecApprovalNeeded?: ExecApprovalCallback | undefined;
|
||||
}
|
||||
|
||||
type ToolErrorPayload = {
|
||||
|
|
@ -100,7 +103,7 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool<
|
|||
(tool) => tool.name !== "bash",
|
||||
) as AgentTool<any>[];
|
||||
|
||||
const execTool = createExecTool(cwd);
|
||||
const execTool = createExecTool(cwd, opts.onExecApprovalNeeded);
|
||||
const processTool = createProcessTool(cwd);
|
||||
const globTool = createGlobTool(cwd);
|
||||
const webFetchTool = createWebFetchTool();
|
||||
|
|
@ -153,6 +156,7 @@ export function resolveTools(options: AgentOptions): AgentTool<any>[] {
|
|||
profileBaseDir: options.profileBaseDir,
|
||||
isSubagent: options.isSubagent,
|
||||
sessionId: options.sessionId,
|
||||
onExecApprovalNeeded: options.onExecApprovalNeeded,
|
||||
});
|
||||
|
||||
// Apply policy filtering
|
||||
|
|
|
|||
187
src/agent/tools/exec-approval-cli.ts
Normal file
187
src/agent/tools/exec-approval-cli.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* CLI Terminal Approval — readline-based approval for CLI mode (no Hub/Gateway)
|
||||
*/
|
||||
|
||||
import readline from "readline";
|
||||
import type {
|
||||
ExecApprovalCallback,
|
||||
ExecApprovalConfig,
|
||||
ApprovalDecision,
|
||||
ApprovalResult,
|
||||
} from "./exec-approval-types.js";
|
||||
import { DEFAULT_APPROVAL_TIMEOUT_MS } from "./exec-approval-types.js";
|
||||
import { evaluateCommandSafety, requiresApproval } from "./exec-safety.js";
|
||||
import { matchAllowlist, addAllowlistEntry, recordAllowlistUse } from "./exec-allowlist.js";
|
||||
|
||||
/** ANSI color helpers */
|
||||
const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
|
||||
const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
|
||||
const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
|
||||
const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
|
||||
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
|
||||
|
||||
/** Risk level color mapping */
|
||||
function colorRisk(level: string): string {
|
||||
switch (level) {
|
||||
case "dangerous": return red(level);
|
||||
case "needs-review": return yellow(level);
|
||||
case "safe": return green(level);
|
||||
default: return level;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for persisting allowlist changes.
|
||||
* The Hub mode uses ProfileManager; CLI callers provide their own persistence.
|
||||
*/
|
||||
export type AllowlistPersister = (updatedConfig: ExecApprovalConfig) => void;
|
||||
|
||||
/**
|
||||
* Create a CLI-based approval callback that prompts the user in the terminal.
|
||||
*
|
||||
* @param config - Exec approval configuration (security, ask, allowlist, etc.)
|
||||
* @param onConfigUpdate - Optional callback to persist config changes (e.g., allowlist updates)
|
||||
*/
|
||||
export function createCliApprovalCallback(
|
||||
config: ExecApprovalConfig,
|
||||
onConfigUpdate?: AllowlistPersister,
|
||||
): ExecApprovalCallback {
|
||||
// Mutable copy of config for runtime allowlist updates
|
||||
const runtimeConfig = { ...config, allowlist: [...(config.allowlist ?? [])] };
|
||||
|
||||
return async (command: string, cwd: string | undefined): Promise<ApprovalResult> => {
|
||||
const security = runtimeConfig.security ?? "allowlist";
|
||||
const ask = runtimeConfig.ask ?? "on-miss";
|
||||
const timeoutMs = runtimeConfig.timeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
|
||||
|
||||
// Security: deny blocks everything
|
||||
if (security === "deny") {
|
||||
return { approved: false, decision: "deny" };
|
||||
}
|
||||
|
||||
// Security: full allows everything
|
||||
if (security === "full") {
|
||||
return { approved: true, decision: "allow-once" };
|
||||
}
|
||||
|
||||
// Evaluate safety
|
||||
const evaluation = evaluateCommandSafety(command, runtimeConfig);
|
||||
|
||||
// Check if approval is needed
|
||||
const needsApproval = requiresApproval({
|
||||
ask,
|
||||
security,
|
||||
analysisOk: evaluation.analysisOk,
|
||||
allowlistSatisfied: evaluation.allowlistSatisfied,
|
||||
});
|
||||
|
||||
if (!needsApproval) {
|
||||
// Auto-approved: record allowlist usage if it was an allowlist match
|
||||
if (evaluation.allowlistSatisfied) {
|
||||
const match = matchAllowlist(runtimeConfig.allowlist ?? [], command);
|
||||
if (match) {
|
||||
runtimeConfig.allowlist = recordAllowlistUse(runtimeConfig.allowlist ?? [], match, command);
|
||||
onConfigUpdate?.(runtimeConfig);
|
||||
}
|
||||
}
|
||||
return { approved: true, decision: "allow-once" };
|
||||
}
|
||||
|
||||
// Prompt user in terminal
|
||||
const decision = await promptTerminal(command, cwd, evaluation.riskLevel, evaluation.reasons, timeoutMs);
|
||||
|
||||
if (decision === "allow-always") {
|
||||
// Extract binary or full command as allowlist pattern
|
||||
const pattern = extractAllowlistPattern(command);
|
||||
runtimeConfig.allowlist = addAllowlistEntry(runtimeConfig.allowlist ?? [], pattern);
|
||||
onConfigUpdate?.(runtimeConfig);
|
||||
}
|
||||
|
||||
return {
|
||||
approved: decision !== "deny",
|
||||
decision,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an allowlist pattern from a command.
|
||||
* Uses the binary name + "**" for broad matching.
|
||||
*/
|
||||
function extractAllowlistPattern(command: string): string {
|
||||
const trimmed = command.trim();
|
||||
const binary = trimmed.split(/\s+/)[0];
|
||||
return binary ? `${binary} **` : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user for an approval decision via readline.
|
||||
*/
|
||||
function promptTerminal(
|
||||
command: string,
|
||||
cwd: string | undefined,
|
||||
riskLevel: string,
|
||||
reasons: string[],
|
||||
timeoutMs: number,
|
||||
): Promise<ApprovalDecision> {
|
||||
return new Promise((resolve) => {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stderr, // Use stderr to avoid mixing with stdout piping
|
||||
});
|
||||
|
||||
let resolved = false;
|
||||
const cleanup = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
rl.close();
|
||||
};
|
||||
|
||||
// Timeout: auto-deny
|
||||
const timer = setTimeout(() => {
|
||||
if (resolved) return;
|
||||
process.stderr.write(dim(`\n Approval timed out (${timeoutMs / 1000}s). Denying.\n\n`));
|
||||
cleanup();
|
||||
resolve("deny");
|
||||
}, timeoutMs);
|
||||
|
||||
// Display approval prompt
|
||||
process.stderr.write("\n");
|
||||
process.stderr.write(bold(" Exec approval required\n"));
|
||||
process.stderr.write(` ${dim("Command:")} ${command}\n`);
|
||||
if (cwd) process.stderr.write(` ${dim("CWD:")} ${cwd}\n`);
|
||||
process.stderr.write(` ${dim("Risk:")} ${colorRisk(riskLevel)}\n`);
|
||||
if (reasons.length > 0) {
|
||||
for (const reason of reasons) {
|
||||
process.stderr.write(` ${dim(" -")} ${reason}\n`);
|
||||
}
|
||||
}
|
||||
process.stderr.write("\n");
|
||||
|
||||
rl.question(
|
||||
` ${bold("[a]")}llow once / ${bold("[A]")}llow always / ${bold("[d]")}eny (default: deny): `,
|
||||
(answer) => {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
|
||||
const trimmed = answer.trim();
|
||||
if (trimmed === "a" || trimmed === "allow-once") {
|
||||
resolve("allow-once");
|
||||
} else if (trimmed === "A" || trimmed === "allow-always") {
|
||||
resolve("allow-always");
|
||||
} else {
|
||||
resolve("deny");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Handle Ctrl+C gracefully
|
||||
rl.on("close", () => {
|
||||
clearTimeout(timer);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve("deny");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
getFullOutput,
|
||||
PROCESS_REGISTRY,
|
||||
} from "./process-registry.js";
|
||||
import type { ExecApprovalCallback } from "./exec-approval-types.js";
|
||||
|
||||
const ExecSchema = Type.Object({
|
||||
command: Type.String({ description: "Shell command to execute." }),
|
||||
|
|
@ -40,7 +41,10 @@ export type ExecResult = {
|
|||
|
||||
const DEFAULT_YIELD_MS = 10000; // Changed from 5000 to 10000
|
||||
|
||||
export function createExecTool(defaultCwd?: string): AgentTool<typeof ExecSchema, ExecResult> {
|
||||
export function createExecTool(
|
||||
defaultCwd?: string,
|
||||
onApprovalNeeded?: ExecApprovalCallback,
|
||||
): AgentTool<typeof ExecSchema, ExecResult> {
|
||||
return {
|
||||
name: "exec",
|
||||
label: "Exec",
|
||||
|
|
@ -51,6 +55,21 @@ export function createExecTool(defaultCwd?: string): AgentTool<typeof ExecSchema
|
|||
const { command, cwd, timeoutMs, yieldMs = DEFAULT_YIELD_MS } = args as ExecArgs;
|
||||
const effectiveCwd = cwd || defaultCwd;
|
||||
|
||||
// Exec approval: ask for permission before executing
|
||||
if (onApprovalNeeded) {
|
||||
const approvalResult = await onApprovalNeeded(command, effectiveCwd);
|
||||
if (!approvalResult.approved) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Command execution denied by user." }],
|
||||
details: {
|
||||
output: "Command execution denied by user.",
|
||||
exitCode: 1,
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, {
|
||||
shell: true,
|
||||
|
|
|
|||
|
|
@ -32,3 +32,20 @@ export {
|
|||
getSubagentPolicy,
|
||||
wouldToolBeAllowed,
|
||||
} from "./policy.js";
|
||||
|
||||
// Exec approval system
|
||||
export type {
|
||||
ExecSecurity,
|
||||
ExecAsk,
|
||||
ApprovalDecision,
|
||||
ExecApprovalRequest,
|
||||
ExecApprovalConfig,
|
||||
ExecAllowlistEntry,
|
||||
ExecApprovalCallback,
|
||||
ApprovalResult,
|
||||
SafetyEvaluation,
|
||||
} from "./exec-approval-types.js";
|
||||
export { DEFAULT_APPROVAL_TIMEOUT_MS } from "./exec-approval-types.js";
|
||||
export { evaluateCommandSafety, requiresApproval, minSecurity, maxAsk, DEFAULT_SAFE_BINS } from "./exec-safety.js";
|
||||
export { matchAllowlist, addAllowlistEntry, recordAllowlistUse, removeAllowlistEntry, normalizeAllowlist } from "./exec-allowlist.js";
|
||||
export { createCliApprovalCallback } from "./exec-approval-cli.js";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import type { SkillsConfig } from "./skills/types.js";
|
||||
import type { ToolsConfig } from "./tools/policy.js";
|
||||
import type { ExecApprovalCallback, ExecApprovalConfig } from "./tools/exec-approval-types.js";
|
||||
|
||||
/** Controls how reasoning/thinking content blocks are handled */
|
||||
export type ReasoningMode = "off" | "on" | "stream";
|
||||
|
|
@ -75,6 +76,12 @@ export type AgentOptions = {
|
|||
tools?: ToolsConfig | undefined;
|
||||
/** Whether this is a subagent (applies restricted tool set) */
|
||||
isSubagent?: boolean | undefined;
|
||||
|
||||
// === Exec Approval Configuration ===
|
||||
/** Callback invoked when exec tool needs approval before running a command */
|
||||
onExecApprovalNeeded?: ExecApprovalCallback | undefined;
|
||||
/** Exec approval configuration (security level, ask mode, allowlist) */
|
||||
execApproval?: ExecApprovalConfig | undefined;
|
||||
};
|
||||
|
||||
export interface Message {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue