From cc89a0f48fce63953f5a1800fad1204250bca29b Mon Sep 17 00:00:00 2001 From: ysc13245 Date: Thu, 19 Feb 2026 21:52:22 +0900 Subject: [PATCH 1/4] feat: support prefix-based allowlist for Shell.exec --- src/core/Interceptor.ts | 72 ++++++++++++++++++++++++++++++++++++----- src/types.ts | 1 + 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/core/Interceptor.ts b/src/core/Interceptor.ts index 625aa53..7336d02 100644 --- a/src/core/Interceptor.ts +++ b/src/core/Interceptor.ts @@ -42,7 +42,7 @@ export class Interceptor { args: unknown[], sessionKey?: string ): Promise { - const rule = this.lookupRule(moduleName, methodName); + const rule = this.lookupRuleWithArgs(moduleName, methodName, args); if (this.logEnabled) { this.logInterception(moduleName, methodName, rule.action); @@ -59,17 +59,17 @@ export class Interceptor { if (isChannelMode) { const instructions = this.respondToolAvailable ? `Ask the user: YES, NO, or ALLOW (auto-approve for 15 min).\n` + - `- YES → clawbands_respond({ decision: "yes" }), then retry.\n` + - `- NO → clawbands_respond({ decision: "no" }). Do NOT retry.\n` + - `- ALLOW → clawbands_respond({ decision: "allow" }), then retry. Auto-approves this action for 15 minutes.` + `- YES → clawbands_respond({ decision: "yes" }), then retry.\n` + + `- NO → clawbands_respond({ decision: "no" }). Do NOT retry.\n` + + `- ALLOW → clawbands_respond({ decision: "allow" }), then retry. Auto-approves this action for 15 minutes.` : `Ask the user YES or NO.\n` + - `- If YES: call ${moduleName}.${methodName}() again exactly as before.\n` + - `- If NO: do NOT call the tool again. Tell the user the action was cancelled.`; + `- If YES: call ${moduleName}.${methodName}() again exactly as before.\n` + + `- If NO: do NOT call the tool again. Tell the user the action was cancelled.`; throw new Error( `[ClawBands:APPROVAL_REQUIRED] ${moduleName}.${methodName}() is blocked pending human approval. ` + - `Risk: ${detail}\n` + - instructions + `Risk: ${detail}\n` + + instructions ); } @@ -193,4 +193,60 @@ export class Interceptor { logger.info(`${chalk.cyan('ClawBands:')} ${moduleName}.${methodName}() → ${coloredAction}`); } + + /** + * Resolves the effective security rule for a tool call. + * @returns The resolved SecurityRule + */ + private lookupRuleWithArgs( + moduleName: string, + methodName: string, + args: unknown[] + ): SecurityRule { + const base = this.lookupRule(moduleName, methodName); + + if (moduleName === 'Shell' && methodName === 'exec') { + const first = args?.[0] as any; + + const cmd: string = + typeof first?.command === 'string' + ? first.command + : Array.isArray(first?.argv) + ? first.argv.join(' ') + : typeof first === 'string' + ? first + : ''; + + if (cmd && this.policy.commandAllow?.length) { + const tokens = cmd.trim().split(/\s+/); + + for (const prefix of this.policy.commandAllow) { + if (this.startsWithTokens(tokens, prefix)) { + return { + action: 'ALLOW', + description: `Matched commandAllow: ${prefix.join(' ')}` + }; + } + } + } + } + + return base; + } + + /** + * Checks whether a token array starts with the given prefix tokens. + * @param tokens - Tokenized command + * @param prefix - Allowed prefix tokens + * @returns True if prefix matches + */ + private startsWithTokens(tokens: string[], prefix: string[]): boolean { + if (tokens.length < prefix.length) return false; + + for (let i = 0; i < prefix.length; i++) { + if (tokens[i] !== prefix[i]) return false; + } + + return true; + } } diff --git a/src/types.ts b/src/types.ts index 8d6426b..b315c7d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,7 @@ export interface SecurityPolicy { [methodName: string]: SecurityRule; }; }; + commandAllow?: string[][]; } /** From 4ef993f76d79f5749fe3f38174f19fd1a77d6385 Mon Sep 17 00:00:00 2001 From: ysc13245 Date: Thu, 19 Feb 2026 22:17:07 +0900 Subject: [PATCH 2/4] docs: document commandAllow prefix-based allowlist for Shell.exec --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index 3273e87..5e86372 100644 --- a/README.md +++ b/README.md @@ -100,12 +100,38 @@ ClawBands uses three decision types: | **ASK** | Prompt for approval (e.g., file writes) | | **DENY** | Block automatically (e.g., file deletes) | +In addition, `commandAllow` enables prefix-based allow rules for `Shell.exec`. + Default policy (Balanced): - FileSystem: read=ALLOW, write=ASK, delete=DENY - Shell: bash=ASK, exec=ASK - Network: fetch=ASK, request=ASK - Everything else: ASK (fail-secure default) +### Command allowlist for Shell.exec + +You can allow common diagnostic commands for `Shell.exec` without prompts using `commandAllow`. +This applies to `Shell.exec` only (not `Shell.bash`). + +Example: + +```json +{ + "commandAllow": [ + ["docker", "logs"], + ["docker", "ps"], + ["openclaw", "config", "get"], + ["journalctl"], + ["ls"], + ["cat"] + ] +} +``` + +Rules are matched by token prefix. +For example, `["docker","logs"]` matches `docker logs nginx -f`. + + ## CLI Commands ```bash From e02abac6a08ba166cea0c287e8baaa072346f1af Mon Sep 17 00:00:00 2001 From: ysc13245 Date: Thu, 19 Feb 2026 22:51:11 +0900 Subject: [PATCH 3/4] style: format --- src/core/Interceptor.ts | 16 ++++++++-------- src/plugin/index.ts | 6 ++++-- src/plugin/tool-interceptor.ts | 5 ++++- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/core/Interceptor.ts b/src/core/Interceptor.ts index 7336d02..2d8a219 100644 --- a/src/core/Interceptor.ts +++ b/src/core/Interceptor.ts @@ -59,17 +59,17 @@ export class Interceptor { if (isChannelMode) { const instructions = this.respondToolAvailable ? `Ask the user: YES, NO, or ALLOW (auto-approve for 15 min).\n` + - `- YES → clawbands_respond({ decision: "yes" }), then retry.\n` + - `- NO → clawbands_respond({ decision: "no" }). Do NOT retry.\n` + - `- ALLOW → clawbands_respond({ decision: "allow" }), then retry. Auto-approves this action for 15 minutes.` + `- YES → clawbands_respond({ decision: "yes" }), then retry.\n` + + `- NO → clawbands_respond({ decision: "no" }). Do NOT retry.\n` + + `- ALLOW → clawbands_respond({ decision: "allow" }), then retry. Auto-approves this action for 15 minutes.` : `Ask the user YES or NO.\n` + - `- If YES: call ${moduleName}.${methodName}() again exactly as before.\n` + - `- If NO: do NOT call the tool again. Tell the user the action was cancelled.`; + `- If YES: call ${moduleName}.${methodName}() again exactly as before.\n` + + `- If NO: do NOT call the tool again. Tell the user the action was cancelled.`; throw new Error( `[ClawBands:APPROVAL_REQUIRED] ${moduleName}.${methodName}() is blocked pending human approval. ` + - `Risk: ${detail}\n` + - instructions + `Risk: ${detail}\n` + + instructions ); } @@ -224,7 +224,7 @@ export class Interceptor { if (this.startsWithTokens(tokens, prefix)) { return { action: 'ALLOW', - description: `Matched commandAllow: ${prefix.join(' ')}` + description: `Matched commandAllow: ${prefix.join(' ')}`, }; } } diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 2158b0f..b950c0c 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -45,14 +45,16 @@ function tryRegisterTool(api: OpenClawPluginApi): boolean { } api.registerTool({ name: CLAWBANDS_RESPOND_TOOL, - description: 'Respond to a ClawBands security prompt. Call after the user says YES, NO, or ALLOW.', + description: + 'Respond to a ClawBands security prompt. Call after the user says YES, NO, or ALLOW.', parameters: { type: 'object', properties: { decision: { type: 'string', enum: ['yes', 'no', 'allow'], - description: 'The user decision: "yes" to approve once, "no" to deny, "allow" to auto-approve for 15 minutes.', + description: + 'The user decision: "yes" to approve once, "no" to deny, "allow" to auto-approve for 15 minutes.', }, }, required: ['decision'], diff --git a/src/plugin/tool-interceptor.ts b/src/plugin/tool-interceptor.ts index e483661..7a7c346 100644 --- a/src/plugin/tool-interceptor.ts +++ b/src/plugin/tool-interceptor.ts @@ -151,7 +151,10 @@ function handleRespondTool( const count = approvalQueue.approve(sessionKey); const rules = pending.map((p) => `${p.moduleName}.${p.methodName}`).join(', '); logger.info(`[${CLAWBANDS_RESPOND_TOOL}] ALLOW for 15 min`, { sessionKey, rules, count }); - return { block: true, blockReason: `Approved for 15 minutes: ${rules}. Retry the blocked tool.` }; + return { + block: true, + blockReason: `Approved for 15 minutes: ${rules}. Retry the blocked tool.`, + }; } logger.warn(`[${CLAWBANDS_RESPOND_TOOL}] Invalid decision: "${params.decision}"`, { sessionKey }); From 134860660b3c749ce8be1ca49982075dff5c60c8 Mon Sep 17 00:00:00 2001 From: ysc13245 Date: Thu, 19 Feb 2026 23:10:00 +0900 Subject: [PATCH 4/4] refactor: extract command parsing logic for Shell.exec --- src/core/Interceptor.ts | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/core/Interceptor.ts b/src/core/Interceptor.ts index 2d8a219..01d7d7a 100644 --- a/src/core/Interceptor.ts +++ b/src/core/Interceptor.ts @@ -205,17 +205,10 @@ export class Interceptor { ): SecurityRule { const base = this.lookupRule(moduleName, methodName); - if (moduleName === 'Shell' && methodName === 'exec') { - const first = args?.[0] as any; + if (base.action !== 'ASK') return base; - const cmd: string = - typeof first?.command === 'string' - ? first.command - : Array.isArray(first?.argv) - ? first.argv.join(' ') - : typeof first === 'string' - ? first - : ''; + if (moduleName === 'Shell' && methodName === 'exec') { + const cmd = this.extractCommand(args); if (cmd && this.policy.commandAllow?.length) { const tokens = cmd.trim().split(/\s+/); @@ -249,4 +242,29 @@ export class Interceptor { return true; } + + /** + * Extracts a command string from Shell.exec args. + * @param args - Tool call arguments + * @returns The command string, or empty string if unavailable + */ + private extractCommand(args: unknown[]): string { + const first = args?.[0]; + + if (typeof first === 'string') return first; + + if (!first || typeof first !== 'object') return ''; + + const obj = first as Record; + + if (typeof obj.command === 'string') return obj.command; + + if (Array.isArray(obj.argv)) { + const parts = obj.argv.filter((x): x is string => typeof x === 'string'); + if (parts.length > 0) return parts.join(' '); + } + + return ''; + } } +