diff --git a/packages/core/src/agent/runner.skill-install-consent.test.ts b/packages/core/src/agent/runner.skill-install-consent.test.ts new file mode 100644 index 00000000..94aea9a7 --- /dev/null +++ b/packages/core/src/agent/runner.skill-install-consent.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; +import { + evaluateCustomSkillAuthoringConsent, + evaluateWorkaroundConsent, + evaluateSkillInstallConsent, + isEnvironmentInstallCommand, + isLocalSkillMutationCommand, + isMutatingClawhubCommand, + isThirdPartyWorkaroundCommand, +} from "./runner.js"; + +describe("isMutatingClawhubCommand", () => { + it("detects clawhub install command", () => { + expect( + isMutatingClawhubCommand("npx -y clawhub install spotify --workdir /tmp --dir skills"), + ).toBe(true); + }); + + it("detects clawhub update command", () => { + expect(isMutatingClawhubCommand("clawhub update spotify --force")).toBe(true); + }); + + it("does not match non-mutating clawhub commands", () => { + expect(isMutatingClawhubCommand("clawhub search spotify --limit 10")).toBe(false); + expect(isMutatingClawhubCommand("clawhub inspect spotify")).toBe(false); + }); + + it("detects wrapped bash flow that expands CLAWHUB_CMD and runs install", () => { + const command = [ + "cd /tmp/meta-skill-installer && bash -c '", + "if command -v clawhub >/dev/null 2>&1; then", + " CLAWHUB_CMD=(clawhub)", + "else", + " CLAWHUB_CMD=(npx -y clawhub)", + "fi", + "\"${CLAWHUB_CMD[@]}\" install \"spotify\" --workdir \"$DATA_DIR\" --dir skills --force", + "'", + ].join("\n"); + expect(isMutatingClawhubCommand(command)).toBe(true); + }); +}); + +describe("evaluateSkillInstallConsent", () => { + it("does not grant consent for generic capability requests", () => { + const result = evaluateSkillInstallConsent("随机播放 spotify 中的音乐", false); + expect(result).toEqual({ allowInstall: false, declined: false }); + }); + + it("grants consent for explicit install requests", () => { + const result = evaluateSkillInstallConsent("请帮我安装 spotify skill", false); + expect(result).toEqual({ allowInstall: true, declined: false }); + }); + + it("grants consent for short affirmative replies when awaiting confirmation", () => { + const result = evaluateSkillInstallConsent("继续", true); + expect(result).toEqual({ allowInstall: true, declined: false }); + }); + + it("treats standalone Chinese affirmative as consent when awaiting confirmation", () => { + const result = evaluateSkillInstallConsent("行", true); + expect(result).toEqual({ allowInstall: true, declined: false }); + }); + + it("marks declines explicitly", () => { + const result = evaluateSkillInstallConsent("不要安装,先别动", true); + expect(result).toEqual({ allowInstall: false, declined: true }); + }); +}); + +describe("isEnvironmentInstallCommand", () => { + it("detects package manager install commands", () => { + expect(isEnvironmentInstallCommand("brew install spogo")).toBe(true); + expect(isEnvironmentInstallCommand("pnpm add lodash")).toBe(true); + expect(isEnvironmentInstallCommand("npm install -g clawhub")).toBe(true); + expect(isEnvironmentInstallCommand("pip install requests")).toBe(true); + }); + + it("does not match read-only package manager commands", () => { + expect(isEnvironmentInstallCommand("brew list")).toBe(false); + expect(isEnvironmentInstallCommand("pnpm list --depth 0")).toBe(false); + expect(isEnvironmentInstallCommand("npm view clawhub")).toBe(false); + }); +}); + +describe("isThirdPartyWorkaroundCommand", () => { + it("detects local workaround commands", () => { + expect(isThirdPartyWorkaroundCommand("spotify_player playback shuffle")).toBe(true); + expect(isThirdPartyWorkaroundCommand("spogo status")).toBe(true); + expect(isThirdPartyWorkaroundCommand("osascript -e 'tell app \"Spotify\" to play'")).toBe(true); + expect(isThirdPartyWorkaroundCommand("curl http://localhost:8123/api/states")).toBe(true); + }); + + it("does not match unrelated commands", () => { + expect(isThirdPartyWorkaroundCommand("ls -la")).toBe(false); + expect(isThirdPartyWorkaroundCommand("pnpm test")).toBe(false); + }); +}); + +describe("evaluateWorkaroundConsent", () => { + it("does not grant workaround mode for generic capability requests", () => { + const result = evaluateWorkaroundConsent("随机播放 spotify 中的音乐", false); + expect(result).toEqual({ allowWorkaround: false, declined: false }); + }); + + it("grants workaround mode for explicit local-command intent", () => { + const result = evaluateWorkaroundConsent("不要安装 skill,直接用本地命令试试", false); + expect(result).toEqual({ allowWorkaround: true, declined: false }); + }); + + it("grants workaround mode for short affirmative replies when awaiting confirmation", () => { + const result = evaluateWorkaroundConsent("继续", true); + expect(result).toEqual({ allowWorkaround: true, declined: false }); + }); + + it("treats standalone Chinese affirmative as workaround consent when awaiting confirmation", () => { + const result = evaluateWorkaroundConsent("行", true); + expect(result).toEqual({ allowWorkaround: true, declined: false }); + }); + + it("marks declines when no workaround intent is present", () => { + const result = evaluateWorkaroundConsent("不要,先别执行", true); + expect(result).toEqual({ allowWorkaround: false, declined: true }); + }); +}); + +describe("isLocalSkillMutationCommand", () => { + it("detects direct local skill mutation commands", () => { + expect( + isLocalSkillMutationCommand( + "mkdir -p ~/.super-multica/skills/notion-integration && touch ~/.super-multica/skills/notion-integration/SKILL.md", + ), + ).toBe(true); + + expect( + isLocalSkillMutationCommand( + "cat > ~/.super-multica/skills/notion-integration/SKILL.md << 'EOF'\n# skill\nEOF", + ), + ).toBe(true); + }); + + it("does not match read-only commands or clawhub install flow", () => { + expect(isLocalSkillMutationCommand("cat ~/.super-multica/skills/notion/SKILL.md")).toBe(false); + expect( + isLocalSkillMutationCommand( + "npx -y clawhub install notion --workdir ~/.super-multica --dir skills --force", + ), + ).toBe(false); + }); +}); + +describe("evaluateCustomSkillAuthoringConsent", () => { + it("does not grant consent for generic third-party requests", () => { + const result = evaluateCustomSkillAuthoringConsent("帮我在 Notion 新建一个页面", false); + expect(result).toEqual({ allowAuthoring: false, declined: false }); + }); + + it("grants consent when user explicitly asks to create a custom skill", () => { + const result = evaluateCustomSkillAuthoringConsent("请帮我创建一个 Notion skill", false); + expect(result).toEqual({ allowAuthoring: true, declined: false }); + }); + + it("grants consent for short affirmatives when awaiting confirmation", () => { + const result = evaluateCustomSkillAuthoringConsent("继续", true); + expect(result).toEqual({ allowAuthoring: true, declined: false }); + }); + + it("marks declines explicitly", () => { + const result = evaluateCustomSkillAuthoringConsent("先别创建技能", true); + expect(result).toEqual({ allowAuthoring: false, declined: true }); + }); +}); diff --git a/packages/core/src/agent/runner.ts b/packages/core/src/agent/runner.ts index f36fe596..0b51292a 100644 --- a/packages/core/src/agent/runner.ts +++ b/packages/core/src/agent/runner.ts @@ -50,6 +50,7 @@ import { import { isContextOverflowError } from "./errors.js"; import { resolveWorkspaceDir, ensureWorkspaceDir } from "./workspace.js"; import { createRunLog, type RunLog } from "./run-log.js"; +import type { ExecApprovalCallback } from "./tools/exec-approval-types.js"; // ============================================================ // Error classification for auth profile rotation @@ -83,6 +84,153 @@ export function isRotatableError(reason: AuthProfileFailureReason): boolean { return reason === "auth" || reason === "rate_limit" || reason === "billing" || reason === "timeout"; } +// ── Skill install consent guard ───────────────────────────────────────────── + +const CLAWHUB_MUTATION_RE = /\bclawhub\b[\s\S]*\b(?:install|update)\b/i; +const ENV_INSTALL_RE = /\b(?:brew|apt-get|apt|yum|dnf|pacman|zypper)\s+(?:install|upgrade|tap)\b|\b(?:npm|pnpm|yarn|bun)\s+(?:install|add)\b|\bpip(?:3)?\s+install\b|\buv\s+(?:tool\s+install|pip\s+install)\b|\bcargo\s+install\b|\bgo\s+install\b/i; +const THIRD_PARTY_WORKAROUND_RE = /\b(?:osascript|spogo|spotify_player|ha\.sh|homeassistant|hass)\b|\/api\/states\b/i; +const LOCAL_SKILL_PATH_RE = /(?:~\/\.super-multica(?:-[\w-]+)?\/skills\/|\/\.super-multica(?:-[\w-]+)?\/skills\/|\/skills\/)/i; +const LOCAL_SKILL_MUTATION_VERB_RE = /\b(?:mkdir|cp|mv|rm|touch|install|clone)\b/i; +const INSTALL_ACTION_RE = /\b(?:install|update|add)\b|安装|更新|添加|启用|配置/i; +const SKILL_CONTEXT_RE = /\b(?:clawhub|skill|skills)\b|技能|插件|扩展/i; +const WORKAROUND_ACTION_RE = /\b(?:workaround|fallback|local\s+command|local\s+script|shell\s+script|osascript|apple\s*script|spogo|spotify_player|homeassistant|ha\.sh)\b|绕过|临时方案|本地命令|本机命令|脚本方式|直接执行|不用技能|不用skill|不装skill|不安装skill/i; +const CUSTOM_SKILL_AUTHORING_RE = /\b(?:create|author|build)\b[\s\S]*\bskills?\b|创建[\s\S]{0,30}(?:技能|skill)|自定义[\s\S]{0,20}(?:技能|skill)|手写[\s\S]{0,20}(?:技能|skill)|custom\s+skill/i; +const AFFIRMATIVE_RE = /\b(?:yes|y|ok|okay|sure|confirm|confirmed|continue|go ahead|please do|do it)\b|继续|确认|同意|可以|好的|继续安装/i; +const STANDALONE_AFFIRMATIVE_RE = /^\s*(?:行|行吧|行的)\s*[。!!]?$/i; +const DECLINE_RE = /\b(?:no|cancel|stop|don't|do not|not now|skip)\b|不要|不需要|取消|先别|暂时不用/i; + +function hasAffirmativeConsent(text: string): boolean { + return AFFIRMATIVE_RE.test(text) || STANDALONE_AFFIRMATIVE_RE.test(text); +} + +/** + * Detect mutating ClawHub commands that require explicit user confirmation. + */ +export function isMutatingClawhubCommand(command: string): boolean { + return CLAWHUB_MUTATION_RE.test(command); +} + +/** + * Detect package/environment installation commands. + * These mutate the runtime environment and should require explicit user confirmation. + */ +export function isEnvironmentInstallCommand(command: string): boolean { + return ENV_INSTALL_RE.test(command); +} + +/** + * Detect local workaround commands for third-party integrations. + * These should require explicit user opt-in before execution. + */ +export function isThirdPartyWorkaroundCommand(command: string): boolean { + return THIRD_PARTY_WORKAROUND_RE.test(command); +} + +/** + * Detect direct local skill mutations outside ClawHub install/update flow. + */ +export function isLocalSkillMutationCommand(command: string): boolean { + if (!LOCAL_SKILL_PATH_RE.test(command)) return false; + if (/\bclawhub\b/i.test(command)) return false; + + if (LOCAL_SKILL_MUTATION_VERB_RE.test(command)) return true; + + const hasCatOrEchoWrite = /\b(?:cat|tee|echo)\b/i.test(command) && />>?|<<\s*['"]?EOF/i.test(command); + return hasCatOrEchoWrite; +} + +/** + * Determine whether the current user prompt grants permission to install/update skills. + * + * If `awaitingConfirmation` is true, short affirmative replies (e.g. "继续", "yes") + * are treated as confirmation. + */ +export function evaluateSkillInstallConsent( + prompt: string, + awaitingConfirmation: boolean, +): { allowInstall: boolean; declined: boolean } { + const text = prompt.trim(); + if (!text) return { allowInstall: false, declined: false }; + + if (DECLINE_RE.test(text)) { + return { allowInstall: false, declined: true }; + } + + const hasInstallAction = INSTALL_ACTION_RE.test(text); + const hasSkillContext = SKILL_CONTEXT_RE.test(text); + const hasAffirmative = hasAffirmativeConsent(text); + + if (hasInstallAction) { + return { allowInstall: true, declined: false }; + } + + if (hasSkillContext && hasAffirmative) { + return { allowInstall: true, declined: false }; + } + + if (awaitingConfirmation && hasAffirmative) { + return { allowInstall: true, declined: false }; + } + + return { allowInstall: false, declined: false }; +} + +/** + * Determine whether the current user prompt explicitly opts into local workaround mode. + */ +export function evaluateWorkaroundConsent( + prompt: string, + awaitingConfirmation: boolean, +): { allowWorkaround: boolean; declined: boolean } { + const text = prompt.trim(); + if (!text) return { allowWorkaround: false, declined: false }; + + const hasWorkaroundAction = WORKAROUND_ACTION_RE.test(text); + const hasAffirmative = hasAffirmativeConsent(text); + + if (hasWorkaroundAction) { + return { allowWorkaround: true, declined: false }; + } + + if (awaitingConfirmation && hasAffirmative) { + return { allowWorkaround: true, declined: false }; + } + + if (DECLINE_RE.test(text)) { + return { allowWorkaround: false, declined: true }; + } + + return { allowWorkaround: false, declined: false }; +} + +/** + * Determine whether the current prompt explicitly opts into custom skill authoring. + */ +export function evaluateCustomSkillAuthoringConsent( + prompt: string, + awaitingConfirmation: boolean, +): { allowAuthoring: boolean; declined: boolean } { + const text = prompt.trim(); + if (!text) return { allowAuthoring: false, declined: false }; + + if (DECLINE_RE.test(text)) { + return { allowAuthoring: false, declined: true }; + } + + const hasAuthoringIntent = CUSTOM_SKILL_AUTHORING_RE.test(text); + const hasAffirmative = hasAffirmativeConsent(text); + + if (hasAuthoringIntent) { + return { allowAuthoring: true, declined: false }; + } + + if (awaitingConfirmation && hasAffirmative) { + return { allowAuthoring: true, declined: false }; + } + + return { allowAuthoring: false, declined: false }; +} + // ── Run-log result extraction helpers ────────────────────────────────────── // Lightweight extractors for tool_end metadata. These mirror the patterns in // cli/output.ts but are kept separate to avoid CLI-specific dependencies. @@ -143,6 +291,13 @@ export class Agent { private readonly runLog: RunLog; private readonly toolStartTimes = new Map(); private initialized = false; + private allowSkillInstallForCurrentRun = false; + private awaitingSkillInstallConfirmation = false; + private allowWorkaroundForCurrentRun = false; + private awaitingWorkaroundConfirmation = false; + private allowCustomSkillAuthoringForCurrentRun = false; + private awaitingCustomSkillAuthoringConfirmation = false; + private readonly guardedExecApproval: ExecApprovalCallback; // Context window settings (for pre-flight compaction) private readonly reserveTokens: number; @@ -186,6 +341,7 @@ export class Agent { // Load session metadata early so stored provider/model can inform defaults this.sessionId = options.sessionId ?? uuidv7(); + this.guardedExecApproval = this.createGuardedExecApprovalCallback(options.onExecApprovalNeeded); this.runLog = createRunLog( options.enableRunLog ?? !!process.env.MULTICA_RUN_LOG, this.sessionId, @@ -396,8 +552,25 @@ export class Agent { // Use this.sessionId (which may be auto-generated) instead of options.sessionId // (which may be undefined). Without this, delegate tool has no session context. this.toolsOptions = mergedToolsConfig - ? { ...options, sessionId: this.sessionId, cwd: effectiveCwd, tools: mergedToolsConfig, profileDir, provider: this.resolvedProvider, runLog: this.runLog } - : { ...options, sessionId: this.sessionId, cwd: effectiveCwd, profileDir, provider: this.resolvedProvider, runLog: this.runLog }; + ? { + ...options, + sessionId: this.sessionId, + cwd: effectiveCwd, + tools: mergedToolsConfig, + profileDir, + provider: this.resolvedProvider, + runLog: this.runLog, + onExecApprovalNeeded: this.guardedExecApproval, + } + : { + ...options, + sessionId: this.sessionId, + cwd: effectiveCwd, + profileDir, + provider: this.resolvedProvider, + runLog: this.runLog, + onExecApprovalNeeded: this.guardedExecApproval, + }; const tools = resolveTools(this.toolsOptions); if (this.debug) { @@ -526,6 +699,42 @@ export class Agent { this._isRunning = true; this._aborted = false; + if (this._internalRun) { + this.allowSkillInstallForCurrentRun = false; + this.allowWorkaroundForCurrentRun = false; + this.allowCustomSkillAuthoringForCurrentRun = false; + } else { + const consent = evaluateSkillInstallConsent(prompt, this.awaitingSkillInstallConfirmation); + if (consent.declined) { + this.awaitingSkillInstallConfirmation = false; + } + this.allowSkillInstallForCurrentRun = consent.allowInstall; + if (consent.allowInstall) { + this.awaitingSkillInstallConfirmation = false; + } + + const workaroundConsent = evaluateWorkaroundConsent(prompt, this.awaitingWorkaroundConfirmation); + if (workaroundConsent.declined) { + this.awaitingWorkaroundConfirmation = false; + } + this.allowWorkaroundForCurrentRun = workaroundConsent.allowWorkaround; + if (workaroundConsent.allowWorkaround) { + this.awaitingWorkaroundConfirmation = false; + } + + const customSkillConsent = evaluateCustomSkillAuthoringConsent( + prompt, + this.awaitingCustomSkillAuthoringConfirmation, + ); + if (customSkillConsent.declined) { + this.awaitingCustomSkillAuthoringConfirmation = false; + } + this.allowCustomSkillAuthoringForCurrentRun = customSkillConsent.allowAuthoring; + if (customSkillConsent.allowAuthoring) { + this.awaitingCustomSkillAuthoringConfirmation = false; + } + } + const runStart = Date.now(); this.runLog.log("run_start", { prompt: prompt.slice(0, 200), @@ -690,6 +899,9 @@ export class Agent { } this._isRunning = false; this._aborted = false; + this.allowSkillInstallForCurrentRun = false; + this.allowWorkaroundForCurrentRun = false; + this.allowCustomSkillAuthoringForCurrentRun = false; this._lastEventSavedAssistant = undefined; this.currentUserDisplayPrompt = undefined; this.currentUserSource = undefined; @@ -697,6 +909,91 @@ export class Agent { } } + private createGuardedExecApprovalCallback( + base?: ExecApprovalCallback, + ): ExecApprovalCallback { + return async (command, cwd) => { + const needsInstallConsent = + isMutatingClawhubCommand(command) || isEnvironmentInstallCommand(command); + const needsWorkaroundConsent = isThirdPartyWorkaroundCommand(command); + const needsCustomSkillAuthoringConsent = isLocalSkillMutationCommand(command); + if (needsInstallConsent && !this.allowSkillInstallForCurrentRun) { + this.awaitingSkillInstallConfirmation = true; + this.runLog.log("install_guard", { + action: "blocked", + reason: "explicit_user_confirmation_required", + command: command.slice(0, 200), + }); + return { + approved: false, + decision: "deny", + message: + "Install command blocked: explicit user confirmation is required first. Ask the user whether to continue installation.", + }; + } + + if (needsInstallConsent) { + this.runLog.log("install_guard", { + action: "allowed", + reason: "user_confirmed", + command: command.slice(0, 200), + }); + } + + if (needsCustomSkillAuthoringConsent && !this.allowCustomSkillAuthoringForCurrentRun) { + this.awaitingCustomSkillAuthoringConfirmation = true; + this.runLog.log("custom_skill_guard", { + action: "blocked", + reason: "explicit_custom_skill_authoring_confirmation_required", + command: command.slice(0, 200), + }); + return { + approved: false, + decision: "deny", + message: + "Manual local skill creation command blocked by policy. Use ClawHub discovery/install flow first, or ask the user to explicitly confirm custom skill authoring.", + }; + } + + if (needsCustomSkillAuthoringConsent) { + this.runLog.log("custom_skill_guard", { + action: "allowed", + reason: "user_confirmed_custom_skill_authoring", + command: command.slice(0, 200), + }); + } + + if (needsWorkaroundConsent && !this.allowWorkaroundForCurrentRun) { + this.awaitingWorkaroundConfirmation = true; + this.runLog.log("workaround_guard", { + action: "blocked", + reason: "explicit_workaround_opt_in_required", + command: command.slice(0, 200), + }); + return { + approved: false, + decision: "deny", + message: + "Local workaround command blocked by policy. First explain the capability gap and ask whether to search/install a Cloud Hub skill, or get explicit user opt-in for workaround mode.", + }; + } + + if (needsWorkaroundConsent) { + this.runLog.log("workaround_guard", { + action: "allowed", + reason: "user_opted_in_workaround_mode", + command: command.slice(0, 200), + }); + } + + if (base) { + return base(command, cwd); + } + + return { approved: true, decision: "allow-once" }; + }; + } + /** * Advance to the next non-cooldown auth profile. * Returns true if a new profile was activated, false if exhausted. diff --git a/packages/core/src/agent/system-prompt/sections.test.ts b/packages/core/src/agent/system-prompt/sections.test.ts index da5b4f08..fb82b409 100644 --- a/packages/core/src/agent/system-prompt/sections.test.ts +++ b/packages/core/src/agent/system-prompt/sections.test.ts @@ -224,6 +224,9 @@ describe("buildSkillsSection", () => { expect(text).toContain("capability gap"); expect(text).toContain("explicit user confirmation"); expect(text).toContain("clawhub install"); + expect(text).toContain("third-party service requests"); + expect(text).toContain("local workaround commands"); + expect(text).toContain("spotify_player"); }); it("surfaces installed skill IDs and prioritizes meta skill guidance when present", () => { @@ -240,6 +243,7 @@ describe("buildSkillsSection", () => { expect(text).toContain("`meta-skill-installer`"); expect(text).toContain("is installed"); expect(text).toContain("ClawHub search"); + expect(text).toContain("run ClawHub discovery first"); }); it("returns empty in minimal mode", () => { diff --git a/packages/core/src/agent/system-prompt/sections.ts b/packages/core/src/agent/system-prompt/sections.ts index 32072dd4..554b3903 100644 --- a/packages/core/src/agent/system-prompt/sections.ts +++ b/packages/core/src/agent/system-prompt/sections.ts @@ -411,20 +411,25 @@ export function buildSkillsSection( "- If multiple could apply: choose the most specific one.", "- If none clearly apply but an **inactive skill** matches the user's intent: suggest activating it.", "- If the request needs a capability you currently lack: do not stop at refusal. Treat it as a capability gap and propose a recovery path.", + "- For third-party service requests (Spotify, Notion, Slack, Jira, etc.), do not jump to ad-hoc shell/app hacks as the default path.", + "- Treat local CLIs/scripts (for example `spogo`, `spotify_player`, `osascript`, `ha.sh`) as workaround mode: only use them after explicit user opt-in.", ); if (hasMetaSkillInstaller) { lines.push( "- `meta-skill-installer` is installed: for capability gaps with no matching installed skill, proactively offer ClawHub search + security review + explicit install confirmation.", + "- With `meta-skill-installer` installed, run ClawHub discovery first (`clawhub search`) before proposing to hand-build a new custom skill.", ); } else { lines.push( "- If `meta-skill-installer` is available and no installed skill matches: proactively offer to search ClawHub for candidates and run security review before install.", + "- Prefer ClawHub discovery over creating a brand-new custom skill from scratch unless the user explicitly asks for custom skill authoring.", ); } lines.push( "- Ask for explicit user confirmation before final `clawhub install` / `clawhub update` unless the user already clearly asked you to install in this turn.", + "- Only use local workaround commands (for example `osascript` or custom shell scripts) if the user explicitly asks for workaround mode or declines skill installation.", "- After install/update, verify the skill path and retry the original user task.", "", budgeted, diff --git a/packages/core/src/agent/tools/exec-approval-types.ts b/packages/core/src/agent/tools/exec-approval-types.ts index 9c32b3da..9b1ab449 100644 --- a/packages/core/src/agent/tools/exec-approval-types.ts +++ b/packages/core/src/agent/tools/exec-approval-types.ts @@ -40,6 +40,8 @@ export interface ExecApprovalRequest { export interface ApprovalResult { approved: boolean; decision: ApprovalDecision; + /** Optional denial/approval message for the exec tool response */ + message?: string | undefined; } // ============ Configuration ============ diff --git a/packages/core/src/agent/tools/exec.ts b/packages/core/src/agent/tools/exec.ts index 41b51550..07686706 100644 --- a/packages/core/src/agent/tools/exec.ts +++ b/packages/core/src/agent/tools/exec.ts @@ -59,10 +59,11 @@ export function createExecTool( if (onApprovalNeeded) { const approvalResult = await onApprovalNeeded(command, effectiveCwd); if (!approvalResult.approved) { + const denialText = approvalResult.message?.trim() || "Command execution denied by user."; return { - content: [{ type: "text", text: "Command execution denied by user." }], + content: [{ type: "text", text: denialText }], details: { - output: "Command execution denied by user.", + output: denialText, exitCode: 1, truncated: false, },