import type { AgentTickClient } from "./agentTickClient"; import { DEFAULT_EXTENSION_CONFIG } from "./defaultConfig"; import { redactForAgentTick } from "./redaction"; import { analyzeShellCommand, executableBasename } from "./shellAnalysis"; import { piSessionIdFromContext, type AgentTickSessionContext } from "./session"; import type { StatusReporter } from "./status"; import type { CommandDisclosurePolicy, CommandRuleConfig, SanctionClassification, SanctionMatch, SanctionsConfig, ShellCommandUnit, ToolSanctionConfig, } from "./types"; export type RiskClassification = SanctionClassification; function getToolCommandInput(toolName: string, input: unknown): string { if (toolName === "bash") { return typeof input === "object" && input !== null && "command" in input ? String((input as { command?: unknown }).command ?? "") : String(input ?? ""); } return typeof input === "string" ? input : JSON.stringify(input ?? {}, null, 2); } function fallbackUnit(raw: string): ShellCommandUnit { return { raw, args: [], argsText: raw, source: "fallback" }; } function unitsForTool(toolName: string, input: unknown, toolConfig: ToolSanctionConfig): ShellCommandUnit[] { const raw = getToolCommandInput(toolName, input); if (toolName === "bash" && toolConfig.parseShell) { const units = analyzeShellCommand(raw); return units.length > 0 ? units : [fallbackUnit(raw)]; } return [fallbackUnit(raw)]; } function compileRegex(value: string | undefined): RegExp | undefined { if (!value) return undefined; try { return new RegExp(value, "i"); } catch { // resolveAgentTickExtensionConfig validates regexes. This fallback keeps // direct classifyToolCall() calls safe with hand-built configs. return undefined; } } function ruleHasMatcher(rule: CommandRuleConfig): boolean { return Boolean(rule.command || rule.commandRegex || rule.argsRegex || rule.rawRegex); } function matchesRule(rule: CommandRuleConfig, unit: ShellCommandUnit, fullRaw: string): boolean { if (!rule.enabled || !ruleHasMatcher(rule)) return false; const command = unit.command ?? ""; const commandBase = executableBasename(command) ?? command; if (rule.command && commandBase !== rule.command && command !== rule.command) return false; const commandRegex = compileRegex(rule.commandRegex); if (rule.commandRegex && !commandRegex) return false; if (commandRegex && !commandRegex.test(commandBase) && !commandRegex.test(command)) return false; const argsRegex = compileRegex(rule.argsRegex); if (rule.argsRegex && !argsRegex) return false; if (argsRegex && !argsRegex.test(unit.argsText)) return false; const rawRegex = compileRegex(rule.rawRegex); if (rule.rawRegex && !rawRegex) return false; if (rawRegex && !rawRegex.test(unit.raw) && !rawRegex.test(fullRaw)) return false; return true; } function matchingRules( rules: Record, unit: ShellCommandUnit, fullRaw: string, ): Array<{ ruleId: string; rule: CommandRuleConfig }> { return Object.entries(rules) .filter(([, rule]) => matchesRule(rule, unit, fullRaw)) .map(([ruleId, rule]) => ({ ruleId, rule })); } function reasonForRule(rule: CommandRuleConfig, fallback: string): string { return rule.reason?.trim() || fallback; } function makeMatch( ruleId: string | undefined, decision: SanctionMatch["decision"], unit: ShellCommandUnit, reason: string, riskClass?: string, ): SanctionMatch { return { ...(ruleId ? { ruleId } : {}), decision, reason, ...(riskClass ? { riskClass } : {}), unit, }; } export function classifyToolCall( toolName: string, input: unknown, config: SanctionsConfig = DEFAULT_EXTENSION_CONFIG.sanctions, ): RiskClassification { const policy = config.policy; const noApproval: RiskClassification = { needsApproval: false, policy, matches: [] }; if (!config.enabled) return noApproval; const toolConfig = config.tools[toolName]; if (!toolConfig?.enabled) return noApproval; const fullRaw = getToolCommandInput(toolName, input); const units = unitsForTool(toolName, input, toolConfig); const matches: SanctionMatch[] = []; for (const unit of units) { const denyMatches = matchingRules(toolConfig.deny, unit, fullRaw); if (denyMatches.length > 0) { for (const { ruleId, rule } of denyMatches) { matches.push(makeMatch( ruleId, "deny", unit, reasonForRule(rule, "Command matched a deny rule"), rule.riskClass, )); } continue; } const allowMatches = matchingRules(toolConfig.allow, unit, fullRaw); if (policy === "approve-by-default" && allowMatches.length === 0) { matches.push(makeMatch( undefined, "default-approval", unit, "Command requires approval by default", "default-approval", )); } } const approvalMatches = matches.filter((match) => match.decision !== "allow"); const first = approvalMatches[0]; return { needsApproval: approvalMatches.length > 0, policy, matches: approvalMatches, ...(first?.riskClass ? { riskClass: first.riskClass } : {}), ...(first?.reason ? { reason: first.reason } : {}), }; } function summarizeMatches(classification: SanctionClassification): string { return classification.matches .map((match) => { const label = match.ruleId ? `${match.ruleId}: ${match.reason}` : match.reason; const risk = match.riskClass ? ` [${match.riskClass}]` : ""; return `- ${label}${risk}\n Command: \`${redactForAgentTick(match.unit.raw, 500)}\``; }) .join("\n"); } export function buildSanctionBody( toolName: string, input: unknown, classificationOrReason: SanctionClassification | string, ): string { if (typeof classificationOrReason === "string") { return [`Risk: ${classificationOrReason}`, `Tool: ${toolName}`, "", "Input:", "```", redactForAgentTick(input), "```"].join("\n"); } const matched = summarizeMatches(classificationOrReason) || "- Command requires approval"; return [ "Approval required before Pi can run this tool call.", `Tool: ${toolName}`, `Policy: ${classificationOrReason.policy}`, "", "Matched command units:", matched, "", "Full input:", "```", redactForAgentTick(input), "```", ].join("\n"); } function commandHasSecretRisk(classification: SanctionClassification | undefined): boolean { return classification?.matches.some((match) => match.riskClass === "secrets" || /secret|token|password|api[-_]?key|credential/i.test(match.reason), ) === true; } export function getSafeCommandForDisclosure( toolName: string, input: unknown, classificationOrRiskClass?: SanctionClassification | string, discloseCommand: CommandDisclosurePolicy = "safe-only", ): string | undefined { if (toolName !== "bash" || discloseCommand === "never") return undefined; const command = typeof input === "object" && input !== null && "command" in input ? String((input as { command?: unknown }).command ?? "") : undefined; if (!command) return undefined; const redacted = redactForAgentTick(command); if (discloseCommand === "always") return redacted; const classification = typeof classificationOrRiskClass === "object" ? classificationOrRiskClass : undefined; const riskClass = typeof classificationOrRiskClass === "string" ? classificationOrRiskClass : classification?.riskClass; if (riskClass === "secrets" || commandHasSecretRisk(classification)) return undefined; return redacted === command ? command : undefined; } function isApprovedResponse(response: any): boolean { const choiceId = response?.choiceId ?? response?.response?.choiceId; const choiceIds = response?.choiceIds ?? response?.response?.choiceIds; return choiceId === "approve" || choiceIds?.includes("approve") === true; } function localApprovalPrompt(toolName: string, classification: SanctionClassification): string { const summary = classification.matches .map((match) => `- ${match.reason}: ${match.unit.raw}`) .join("\n"); return `Approve ${toolName} call?\n\n${summary || "This tool call requires approval."}`; } export async function handleSanctionToolCall(input: { event: any; ctx: any; createClient: (ctx?: any) => AgentTickClient | null; statusReporter: StatusReporter | null; sessionContext?: AgentTickSessionContext; config?: SanctionsConfig; }): Promise<{ block: true; reason: string } | undefined> { const toolName = String(input.event?.toolName ?? input.event?.name ?? ""); const toolInput = input.event?.input ?? input.event?.args ?? input.event; const config = input.config ?? DEFAULT_EXTENSION_CONFIG.sanctions; const classification = classifyToolCall(toolName, toolInput, config); if (!classification.needsApproval) return undefined; const approveLabel = "Approve"; const denyLabel = "Deny"; const localApproval = config.approval.local && input.ctx?.ui?.select ? (input.ctx.ui.select( localApprovalPrompt(toolName, classification), [approveLabel, denyLabel], ) as Promise).then((choice) => choice === approveLabel) : undefined; input.sessionContext?.usePiSessionId(piSessionIdFromContext(input.ctx)); const client = config.approval.remote ? input.createClient(input.ctx) : null; const riskClasses = [...new Set(classification.matches.map((match) => match.riskClass).filter(Boolean))]; const remoteApprovalRequest = client ? client.createApprovalRequest({ requestType: "sanction", title: "Approve Pi command?", ...(input.sessionContext?.current() ?? {}), body: buildSanctionBody(toolName, toolInput, classification), command: getSafeCommandForDisclosure(toolName, toolInput, classification, config.approval.discloseCommand), choices: [ { id: "approve", label: approveLabel, kind: "approve" }, { id: "deny", label: denyLabel, kind: "deny" }, ], metadata: { toolName, policy: classification.policy, riskClasses, ruleIds: classification.matches.map((match) => match.ruleId).filter(Boolean), cwd: process.cwd(), }, }) : undefined; const remoteApproval = remoteApprovalRequest ?.then((request) => client!.waitForApproval(request.id, request.waiterToken, config.approval.timeoutMs)) .then(isApprovedResponse) .catch(() => localApproval ? undefined : false); const remoteApprovalSource = remoteApproval?.then((approved) => { if (approved === undefined) return new Promise(() => undefined); return approved; }); const approvalSources = [localApproval, remoteApprovalSource].filter(Boolean) as Array>; if (approvalSources.length === 0) { return { block: true, reason: "Approval required, but no local UI or Agent Tick approval channel is available" }; } const approved = await Promise.race(approvalSources); if (client && localApproval) { void remoteApprovalRequest ?.then((request) => client.abandonApproval(request.id)) .catch(() => undefined); } if (approved) return undefined; input.statusReporter?.send("blocked", `Blocked ${toolName}: approval denied`); return { block: true, reason: "Approval denied or cancelled" }; }