fix(agent): guard workaround and local skill mutation commands
This commit is contained in:
parent
6fd4819280
commit
4b7f0afb50
6 changed files with 484 additions and 4 deletions
171
packages/core/src/agent/runner.skill-install-consent.test.ts
Normal file
171
packages/core/src/agent/runner.skill-install-consent.test.ts
Normal file
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, number>();
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ============
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue