claude-warden 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/{marketplace.json → .claude-plugin/marketplace.json} +9 -1
- package/README.md +43 -10
- package/dist/index.cjs +33 -15
- package/package.json +1 -1
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-warden",
|
|
3
3
|
"description": "Smart command safety filter for Claude Code",
|
|
4
|
+
"owner": {
|
|
5
|
+
"name": "banyudu"
|
|
6
|
+
},
|
|
4
7
|
"plugins": [
|
|
5
8
|
{
|
|
6
9
|
"name": "claude-warden",
|
|
7
10
|
"description": "Auto-approves safe commands, blocks dangerous ones, prompts for the rest",
|
|
8
|
-
"
|
|
11
|
+
"version": "1.0.1",
|
|
12
|
+
"author": {
|
|
13
|
+
"name": "banyudu"
|
|
14
|
+
},
|
|
15
|
+
"source": "./",
|
|
16
|
+
"category": "security"
|
|
9
17
|
}
|
|
10
18
|
]
|
|
11
19
|
}
|
package/README.md
CHANGED
|
@@ -2,15 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
Smart command safety filter for [Claude Code](https://claude.ai/code). Parses shell commands, evaluates each against configurable safety rules, and returns allow/deny/ask decisions — eliminating unnecessary permission prompts while blocking dangerous commands.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## The problem
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Claude Code's permission system is all-or-nothing. In the default mode, you're prompted for **every** shell command — even `ls`, `cat`, and `grep`. This creates a painful UX where you're clicking "Allow" hundreds of times per session on obviously safe commands. The alternative (yolo mode) disables all prompts, which is dangerous.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
- `sudo`, `shutdown`, `rm -rf /` → **auto-denied**
|
|
11
|
-
- `npm install`, `docker build`, `ssh prod` → **configurable** per-command rules with argument pattern matching
|
|
9
|
+
There's no middle ground: you can't say "allow `git` but block `git push --force`", or "allow `ssh` to my dev server but prompt for production". And compound commands like `npm run build && npm test` trigger a single opaque prompt with no visibility into what's actually being run.
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
## How Warden solves it
|
|
12
|
+
|
|
13
|
+
Warden hooks into Claude Code's `PreToolUse` event and **parses every shell command into an AST** using [bash-parser](https://github.com/vorpaljs/bash-parser). This means it doesn't just see `npm run build && git push --force` as a single string — it walks the AST to extract each individual command, then evaluates them independently against a configurable rule engine.
|
|
14
|
+
|
|
15
|
+
This AST-based approach enables:
|
|
16
|
+
|
|
17
|
+
- **Pipe and chain decomposition**: `cat file | grep pattern | wc -l` is parsed into three commands, each evaluated separately. All safe → auto-allow. One dangerous → deny the whole pipeline.
|
|
18
|
+
- **Argument-aware rules**: `git status` → allow, `git push --force` → prompt. `rm temp.txt` → allow, `rm -rf /` → deny. The evaluator matches against argument patterns, not just command names.
|
|
19
|
+
- **Recursive evaluation of remote commands**: `ssh devserver 'cat /etc/hosts'` → Warden extracts the remote command, parses it through the same pipeline, and allows it. `ssh devserver 'sudo rm -rf /'` → denied. Same for `docker exec`, `kubectl exec`, and `sprite exec`.
|
|
20
|
+
- **Shell wrapper unwrapping**: `sh -c "npm run build && npm test"` → the inner command is extracted and recursively parsed/evaluated, not treated as an opaque string.
|
|
21
|
+
- **Env prefix handling**: `NODE_ENV=production npm run build` → correctly evaluates `npm run build`, ignoring the env prefix.
|
|
22
|
+
- **Subshell detection**: Commands with `$()` or backticks are flagged for prompting, since their actual content can't be statically evaluated.
|
|
23
|
+
|
|
24
|
+
The result: **100+ common dev commands auto-approved**, dangerous commands auto-denied, everything else configurable — with zero changes to how you use Claude Code.
|
|
25
|
+
|
|
26
|
+
### Before and after
|
|
27
|
+
|
|
28
|
+
| Command | Without Warden | With Warden |
|
|
29
|
+
|---------|---------------|-------------|
|
|
30
|
+
| `ls -la` | Prompted | Auto-allowed |
|
|
31
|
+
| `cat file \| grep pattern \| wc -l` | Prompted | Auto-allowed (3 safe commands) |
|
|
32
|
+
| `npm run build && npm test` | Prompted | Auto-allowed |
|
|
33
|
+
| `git push --force origin main` | Prompted | Prompted (force push is risky) |
|
|
34
|
+
| `sudo rm -rf /` | Prompted | Auto-denied |
|
|
35
|
+
| `ssh devserver cat /etc/hosts` | Prompted | Auto-allowed (trusted host + safe cmd) |
|
|
36
|
+
| `ssh devserver sudo rm -rf /` | Prompted | Auto-denied (trusted host + dangerous cmd) |
|
|
14
37
|
|
|
15
38
|
## Install
|
|
16
39
|
|
|
@@ -114,13 +137,23 @@ File readers (`cat`, `head`, `tail`, `less`), search tools (`grep`, `rg`, `find`
|
|
|
114
137
|
### Conditional rules
|
|
115
138
|
Commands like `node`, `npx`, `docker`, `ssh`, `git push --force`, `rm` have argument-aware rules. For example, `git` is allowed but `git push --force` triggers a prompt.
|
|
116
139
|
|
|
140
|
+
### Trusted remote targets
|
|
141
|
+
Configure trusted hosts/containers/contexts to auto-allow connections and recursively evaluate remote commands:
|
|
142
|
+
- **SSH**: `trustedSSHHosts` — also covers `scp` and `rsync`
|
|
143
|
+
- **Docker**: `trustedDockerContainers` — for `docker exec`
|
|
144
|
+
- **kubectl**: `trustedKubectlContexts` — for `kubectl exec` (requires explicit `--context`)
|
|
145
|
+
- **Sprite**: `trustedSprites` — for `sprite exec`/`console`
|
|
146
|
+
|
|
147
|
+
All support glob patterns: `*`, `?`, `[...]`, `[!...]`, `{a,b,c}`
|
|
148
|
+
|
|
117
149
|
## How it works
|
|
118
150
|
|
|
119
151
|
1. Claude Code calls the `PreToolUse` hook before every Bash command
|
|
120
|
-
2. Warden parses the command into
|
|
121
|
-
3.
|
|
122
|
-
4.
|
|
123
|
-
5.
|
|
152
|
+
2. Warden parses the command into an AST via [bash-parser](https://github.com/vorpaljs/bash-parser), walking the tree to extract individual commands from pipes, chains, logical expressions, and subshells
|
|
153
|
+
3. Shell wrappers (`sh -c`, `bash -c`) and remote commands (`ssh`, `docker exec`, `kubectl exec`, `sprite exec`) are recursively parsed and evaluated
|
|
154
|
+
4. Each command is evaluated through the rule hierarchy: global deny patterns → alwaysDeny → alwaysAllow → trusted remote targets → command-specific rules with argument matching → default decision
|
|
155
|
+
5. Results are combined: any deny → deny whole pipeline, any ask → ask, all allow → allow
|
|
156
|
+
6. Returns the decision via stdout JSON (allow/ask) or exit code 2 (deny)
|
|
124
157
|
|
|
125
158
|
## Development
|
|
126
159
|
|
package/dist/index.cjs
CHANGED
|
@@ -18166,33 +18166,39 @@ function convertCommand(node) {
|
|
|
18166
18166
|
const raw = rawParts.join(" ");
|
|
18167
18167
|
return { command, args: args2, envPrefixes, raw };
|
|
18168
18168
|
}
|
|
18169
|
-
function
|
|
18170
|
-
|
|
18169
|
+
function collectCommandExpansions(node) {
|
|
18170
|
+
const commands = [];
|
|
18171
18171
|
if (node.type === "Command") {
|
|
18172
18172
|
const cmd = node;
|
|
18173
18173
|
if (cmd.suffix) {
|
|
18174
18174
|
for (const s of cmd.suffix) {
|
|
18175
18175
|
if (s.type === "Word" && s.expansion) {
|
|
18176
18176
|
for (const exp of s.expansion) {
|
|
18177
|
-
if (exp.type === "CommandExpansion")
|
|
18177
|
+
if (exp.type === "CommandExpansion" && exp.command) {
|
|
18178
|
+
commands.push(exp.command);
|
|
18179
|
+
}
|
|
18178
18180
|
}
|
|
18179
18181
|
}
|
|
18180
18182
|
}
|
|
18181
18183
|
}
|
|
18182
18184
|
if (cmd.name?.expansion) {
|
|
18183
18185
|
for (const exp of cmd.name.expansion) {
|
|
18184
|
-
if (exp.type === "CommandExpansion")
|
|
18186
|
+
if (exp.type === "CommandExpansion" && exp.command) {
|
|
18187
|
+
commands.push(exp.command);
|
|
18188
|
+
}
|
|
18185
18189
|
}
|
|
18186
18190
|
}
|
|
18187
18191
|
}
|
|
18188
|
-
return
|
|
18192
|
+
return commands;
|
|
18189
18193
|
}
|
|
18190
18194
|
function walkNode(node, result) {
|
|
18191
18195
|
switch (node.type) {
|
|
18192
18196
|
case "Command": {
|
|
18193
18197
|
const cmd = node;
|
|
18194
|
-
|
|
18198
|
+
const expansions = collectCommandExpansions(node);
|
|
18199
|
+
if (expansions.length > 0) {
|
|
18195
18200
|
result.hasSubshell = true;
|
|
18201
|
+
result.subshellCommands.push(...expansions);
|
|
18196
18202
|
}
|
|
18197
18203
|
const parsed = convertCommand(cmd);
|
|
18198
18204
|
if (!parsed) break;
|
|
@@ -18205,6 +18211,7 @@ function walkNode(node, result) {
|
|
|
18205
18211
|
if (innerResult.hasSubshell) {
|
|
18206
18212
|
result.hasSubshell = true;
|
|
18207
18213
|
}
|
|
18214
|
+
result.subshellCommands.push(...innerResult.subshellCommands);
|
|
18208
18215
|
}
|
|
18209
18216
|
} else {
|
|
18210
18217
|
result.commands.push(parsed);
|
|
@@ -18249,35 +18256,35 @@ function walkNode(node, result) {
|
|
|
18249
18256
|
}
|
|
18250
18257
|
function parseCommand(input) {
|
|
18251
18258
|
if (!input || !input.trim()) {
|
|
18252
|
-
return { commands: [], hasSubshell: false, parseError: false };
|
|
18259
|
+
return { commands: [], hasSubshell: false, subshellCommands: [], parseError: false };
|
|
18253
18260
|
}
|
|
18254
18261
|
const hasHeredoc = HEREDOC_REGEX.test(input);
|
|
18255
18262
|
if (hasHeredoc) {
|
|
18256
18263
|
const firstLine = input.split("\n")[0];
|
|
18257
18264
|
const cmdPart = firstLine.replace(/<<-?\s*['"]?\w+['"]?.*$/, "").trim();
|
|
18258
18265
|
if (!cmdPart) {
|
|
18259
|
-
return { commands: [], hasSubshell: false, parseError: true };
|
|
18266
|
+
return { commands: [], hasSubshell: false, subshellCommands: [], parseError: true };
|
|
18260
18267
|
}
|
|
18261
18268
|
try {
|
|
18262
18269
|
const ast = (0, import_bash_parser.default)(cmdPart);
|
|
18263
|
-
const result = { commands: [], hasSubshell: false };
|
|
18270
|
+
const result = { commands: [], hasSubshell: false, subshellCommands: [] };
|
|
18264
18271
|
for (const cmd of ast.commands) {
|
|
18265
18272
|
walkNode(cmd, result);
|
|
18266
18273
|
}
|
|
18267
|
-
return { commands: result.commands, hasSubshell: true, parseError: false };
|
|
18274
|
+
return { commands: result.commands, hasSubshell: true, subshellCommands: result.subshellCommands, parseError: false };
|
|
18268
18275
|
} catch {
|
|
18269
|
-
return { commands: [], hasSubshell: true, parseError: true };
|
|
18276
|
+
return { commands: [], hasSubshell: true, subshellCommands: [], parseError: true };
|
|
18270
18277
|
}
|
|
18271
18278
|
}
|
|
18272
18279
|
try {
|
|
18273
18280
|
const ast = (0, import_bash_parser.default)(input);
|
|
18274
|
-
const result = { commands: [], hasSubshell: false };
|
|
18281
|
+
const result = { commands: [], hasSubshell: false, subshellCommands: [] };
|
|
18275
18282
|
for (const cmd of ast.commands) {
|
|
18276
18283
|
walkNode(cmd, result);
|
|
18277
18284
|
}
|
|
18278
|
-
return { commands: result.commands, hasSubshell: result.hasSubshell, parseError: false };
|
|
18285
|
+
return { commands: result.commands, hasSubshell: result.hasSubshell, subshellCommands: result.subshellCommands, parseError: false };
|
|
18279
18286
|
} catch {
|
|
18280
|
-
return { commands: [], hasSubshell: false, parseError: true };
|
|
18287
|
+
return { commands: [], hasSubshell: false, subshellCommands: [], parseError: true };
|
|
18281
18288
|
}
|
|
18282
18289
|
}
|
|
18283
18290
|
|
|
@@ -18289,7 +18296,18 @@ function evaluate(parsed, config) {
|
|
|
18289
18296
|
if (parsed.commands.length === 0) {
|
|
18290
18297
|
return { decision: "allow", reason: "Empty command", details: [] };
|
|
18291
18298
|
}
|
|
18292
|
-
if (parsed.hasSubshell &&
|
|
18299
|
+
if (parsed.hasSubshell && parsed.subshellCommands.length > 0) {
|
|
18300
|
+
for (const subCmd of parsed.subshellCommands) {
|
|
18301
|
+
const subParsed = parseCommand(subCmd);
|
|
18302
|
+
const subResult = evaluate(subParsed, config);
|
|
18303
|
+
if (subResult.decision === "deny") {
|
|
18304
|
+
return { decision: "deny", reason: `Subshell command: ${subResult.reason}`, details: subResult.details };
|
|
18305
|
+
}
|
|
18306
|
+
if (subResult.decision === "ask") {
|
|
18307
|
+
return { decision: "ask", reason: `Subshell command: ${subResult.reason}`, details: subResult.details };
|
|
18308
|
+
}
|
|
18309
|
+
}
|
|
18310
|
+
} else if (parsed.hasSubshell && parsed.subshellCommands.length === 0 && config.askOnSubshell) {
|
|
18293
18311
|
return { decision: "ask", reason: "Command contains subshell/command substitution", details: [] };
|
|
18294
18312
|
}
|
|
18295
18313
|
const details = [];
|