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 diff --git a/src/core/Interceptor.ts b/src/core/Interceptor.ts index 625aa53..01d7d7a 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); @@ -193,4 +193,78 @@ 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 (base.action !== 'ASK') return base; + + if (moduleName === 'Shell' && methodName === 'exec') { + const cmd = this.extractCommand(args); + + 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; + } + + /** + * 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 ''; + } } + 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 }); 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[][]; } /**