Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 75 additions & 1 deletion src/core/Interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export class Interceptor {
args: unknown[],
sessionKey?: string
): Promise<void> {
const rule = this.lookupRule(moduleName, methodName);
const rule = this.lookupRuleWithArgs(moduleName, methodName, args);

if (this.logEnabled) {
this.logInterception(moduleName, methodName, rule.action);
Expand Down Expand Up @@ -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<string, unknown>;

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 '';
}
}

6 changes: 4 additions & 2 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
5 changes: 4 additions & 1 deletion src/plugin/tool-interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface SecurityPolicy {
[methodName: string]: SecurityRule;
};
};
commandAllow?: string[][];
}

/**
Expand Down