claude-warden 1.0.2 → 1.1.0

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/README.md CHANGED
@@ -19,7 +19,8 @@ This AST-based approach enables:
19
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
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
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.
22
+ - **Recursive subshell evaluation**: Commands with `$()` or backticks are extracted, parsed, and recursively evaluated through the same pipeline. `echo $(cat file.txt)` → both `echo` and `cat` are evaluated individually. Only unparseable constructs (heredocs, complex shell syntax) fall back to prompting when `askOnSubshell` is enabled.
23
+ - **Feedback on blocked commands**: When a command is blocked or flagged, Warden provides a system message explaining why and a YAML snippet showing how to allow it in your config.
23
24
 
24
25
  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
 
@@ -70,6 +71,21 @@ Warden works out of the box with sensible defaults. To customize, create a confi
70
71
 
71
72
  Copy [config/warden.default.yaml](config/warden.default.yaml) as a starting point.
72
73
 
74
+ ### Config priority (scoped layers)
75
+
76
+ Config is evaluated in layers with **project > user > default** priority:
77
+
78
+ 1. **Project-level** (`.claude/warden.yaml`) — highest priority
79
+ 2. **User-level** (`~/.claude/warden.yaml`)
80
+ 3. **Built-in defaults**
81
+
82
+ Within each layer, `alwaysDeny` is checked before `alwaysAllow`. The first layer with a matching entry wins. For command-specific rules, the first layer that defines a rule for a given command wins.
83
+
84
+ This means:
85
+ - A project `alwaysDeny` for `curl` overrides a user `alwaysAllow` for `curl`
86
+ - A user `alwaysAllow` for `sudo` overrides the default `alwaysDeny` for `sudo`
87
+ - A project rule for `npm` overrides the default rule for `npm`
88
+
73
89
  ### Config options
74
90
 
75
91
  ```yaml
@@ -79,18 +95,13 @@ defaultDecision: ask
79
95
  # Trigger "ask" for commands with $() or backticks
80
96
  askOnSubshell: true
81
97
 
82
- # Add commands to always allow/deny
98
+ # Add commands to always allow/deny (scoped to this config level)
83
99
  alwaysAllow:
84
100
  - terraform
85
101
  - flyctl
86
102
  alwaysDeny:
87
103
  - nc
88
104
 
89
- # Block patterns (regex against full command string)
90
- globalDeny:
91
- - pattern: 'curl.*evil\.com'
92
- reason: 'Blocked domain'
93
-
94
105
  # Trusted remote targets (auto-allow connection, evaluate remote commands)
95
106
  trustedSSHHosts:
96
107
  - devserver
@@ -103,7 +114,7 @@ trustedKubectlContexts:
103
114
  trustedSprites:
104
115
  - my-sprite
105
116
 
106
- # Per-command rules (override built-in defaults)
117
+ # Per-command rules (override built-in defaults for this scope)
107
118
  rules:
108
119
  - command: npx
109
120
  default: allow
@@ -116,9 +127,14 @@ rules:
116
127
  description: Read-only docker commands
117
128
  ```
118
129
 
119
- ### Config priority
130
+ ## Feedback and `/warden-allow`
131
+
132
+ When Warden blocks or flags a command, it includes a system message explaining:
120
133
 
121
- Project `.claude/warden.yaml` > User `~/.claude/warden.yaml` > Built-in defaults
134
+ 1. **Why** the command was blocked/flagged (per-command reasons)
135
+ 2. **How to allow it** — a ready-to-use YAML snippet for your config
136
+
137
+ Use the `/warden-allow` slash command to apply the suggested config change. It will ask which scope (project or user) to use.
122
138
 
123
139
  ## Built-in defaults
124
140
 
@@ -128,14 +144,11 @@ File readers (`cat`, `head`, `tail`, `less`), search tools (`grep`, `rg`, `find`
128
144
  ### Always denied
129
145
  `sudo`, `su`, `mkfs`, `fdisk`, `dd`, `shutdown`, `reboot`, `iptables`, `crontab`, `systemctl`, `launchctl`
130
146
 
131
- ### Global deny patterns
132
- - `rm -rf` (recursive force delete)
133
- - Direct writes to block devices
134
- - `chmod -R 777`
135
- - Fork bombs
136
-
137
147
  ### Conditional rules
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.
148
+ Commands like `node`, `npx`, `docker`, `ssh`, `git push --force`, `rm`, `chmod` have argument-aware rules. For example:
149
+ - `git` is allowed but `git push --force` triggers a prompt
150
+ - `rm temp.txt` is allowed but `rm -rf /` is denied
151
+ - `chmod 644 file` prompts but `chmod -R 777 /var` is denied
139
152
 
140
153
  ### Trusted remote targets
141
154
  Configure trusted hosts/containers/contexts to auto-allow connections and recursively evaluate remote commands:
@@ -151,9 +164,9 @@ All support glob patterns: `*`, `?`, `[...]`, `[!...]`, `{a,b,c}`
151
164
  1. Claude Code calls the `PreToolUse` hook before every Bash command
152
165
  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
166
  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
167
+ 4. Each command is evaluated through the rule hierarchy: alwaysDeny → alwaysAllow → trusted remote targets → command-specific rules with argument matching → default decision (checked per layer in priority order)
155
168
  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)
169
+ 6. Returns the decision via stdout JSON (allow/ask) or exit code 2 (deny), with a system message explaining the reasoning for deny/ask decisions
157
170
 
158
171
  ## Development
159
172
 
@@ -1,7 +1,11 @@
1
1
  # Claude Warden - Default Configuration Reference
2
2
  #
3
- # Copy to ~/.claude/warden.yaml or .claude/warden.yaml (project-level) to customize.
4
- # Project-level config overrides user-level config, which overrides defaults.
3
+ # Copy to ~/.claude/warden.yaml (user-level) or .claude/warden.yaml (project-level) to customize.
4
+ #
5
+ # Config priority: project > user > built-in defaults.
6
+ # Within each scope, alwaysDeny is checked before alwaysAllow, and the first
7
+ # scope with a matching rule wins (e.g. a project rule overrides a user rule
8
+ # for the same command).
5
9
 
6
10
  # Default decision for commands not covered by any rule: allow | deny | ask
7
11
  defaultDecision: ask
@@ -9,22 +13,17 @@ defaultDecision: ask
9
13
  # If true, commands containing $() or backticks trigger "ask"
10
14
  askOnSubshell: true
11
15
 
12
- # Additional commands to always allow (appended to built-in list)
16
+ # Additional commands to always allow (checked after alwaysDeny within this scope)
13
17
  # alwaysAllow:
14
18
  # - terraform
15
19
  # - flyctl
16
20
  # - my-safe-tool
17
21
 
18
- # Additional commands to always deny (appended to built-in list)
22
+ # Additional commands to always deny (checked first within this scope)
19
23
  # alwaysDeny:
20
24
  # - nc
21
25
  # - ncat
22
26
 
23
- # Additional global deny patterns (regex against full command string)
24
- # globalDeny:
25
- # - pattern: 'curl.*evil\\.com'
26
- # reason: 'Blocked domain'
27
-
28
27
  # Trusted SSH hosts — ssh/scp/rsync to these hosts are auto-allowed.
29
28
  # Remote commands on trusted hosts are recursively evaluated through warden rules.
30
29
  # Supports glob patterns (* wildcards).
@@ -52,16 +51,15 @@ askOnSubshell: true
52
51
  # - my-sprite
53
52
  # - dev-*
54
53
 
55
- # Command-specific rules (override built-in rules by command name)
54
+ # Command-specific rules (override built-in rules by command name).
55
+ # The first scope (project > user > default) with a rule for a given command wins.
56
56
  # rules:
57
57
  # - command: npx
58
58
  # default: allow # Trust all npx in this project
59
59
  # - command: docker
60
- # default: allow # Trust docker in this project
61
- # - command: ssh
62
- # default: allow
60
+ # default: ask
63
61
  # argPatterns:
64
62
  # - match:
65
- # anyArgMatches: ['^devserver$', '^staging$']
63
+ # anyArgMatches: ['^(ps|images|logs)$']
66
64
  # decision: allow
67
- # description: Known safe SSH targets
65
+ # description: Read-only docker commands
package/dist/index.cjs CHANGED
@@ -18335,16 +18335,13 @@ function evaluate(parsed, config) {
18335
18335
  }
18336
18336
  function evaluateCommand(cmd, config) {
18337
18337
  const { command, args: args2 } = cmd;
18338
- for (const gp of config.globalDeny || []) {
18339
- if (new RegExp(gp.pattern).test(cmd.raw)) {
18340
- return { command, args: args2, decision: "deny", reason: gp.reason, matchedRule: "globalDeny" };
18338
+ for (const layer of config.layers) {
18339
+ if (layer.alwaysDeny.includes(command)) {
18340
+ return { command, args: args2, decision: "deny", reason: `"${command}" is blocked`, matchedRule: "alwaysDeny" };
18341
+ }
18342
+ if (layer.alwaysAllow.includes(command)) {
18343
+ return { command, args: args2, decision: "allow", reason: `"${command}" is safe`, matchedRule: "alwaysAllow" };
18341
18344
  }
18342
- }
18343
- if (config.alwaysDeny?.includes(command)) {
18344
- return { command, args: args2, decision: "deny", reason: `"${command}" is blocked`, matchedRule: "alwaysDeny" };
18345
- }
18346
- if (config.alwaysAllow?.includes(command)) {
18347
- return { command, args: args2, decision: "allow", reason: `"${command}" is safe`, matchedRule: "alwaysAllow" };
18348
18345
  }
18349
18346
  if ((command === "ssh" || command === "scp" || command === "rsync") && config.trustedSSHHosts?.length) {
18350
18347
  const sshResult = evaluateSSHCommand(cmd, config);
@@ -18362,9 +18359,11 @@ function evaluateCommand(cmd, config) {
18362
18359
  const spriteResult = evaluateSpriteExec(cmd, config);
18363
18360
  if (spriteResult) return spriteResult;
18364
18361
  }
18365
- const rule = config.rules.find((r) => r.command === command);
18366
- if (rule) {
18367
- return evaluateRule(cmd, rule);
18362
+ for (const layer of config.layers) {
18363
+ const rule = layer.rules.find((r) => r.command === command);
18364
+ if (rule) {
18365
+ return evaluateRule(cmd, rule);
18366
+ }
18368
18367
  }
18369
18368
  return { command, args: args2, decision: config.defaultDecision, reason: `No rule for "${command}"`, matchedRule: "default" };
18370
18369
  }
@@ -18438,7 +18437,6 @@ function globToRegex(pattern) {
18438
18437
  } else if (ch === "?") {
18439
18438
  regex += ".";
18440
18439
  } else if (ch === "[") {
18441
- const start = i;
18442
18440
  i++;
18443
18441
  if (i < pattern.length && pattern[i] === "!") {
18444
18442
  regex += "[^";
@@ -18793,288 +18791,327 @@ var DEFAULT_CONFIG = {
18793
18791
  trustedDockerContainers: [],
18794
18792
  trustedKubectlContexts: [],
18795
18793
  trustedSprites: [],
18796
- alwaysAllow: [
18797
- // Read-only file operations
18798
- "cat",
18799
- "head",
18800
- "tail",
18801
- "less",
18802
- "more",
18803
- "wc",
18804
- "sort",
18805
- "uniq",
18806
- "tee",
18807
- "diff",
18808
- "comm",
18809
- "cut",
18810
- "paste",
18811
- "tr",
18812
- "fold",
18813
- "expand",
18814
- "unexpand",
18815
- "column",
18816
- "rev",
18817
- "tac",
18818
- "nl",
18819
- "od",
18820
- "xxd",
18821
- "file",
18822
- "stat",
18823
- // Search/find
18824
- "grep",
18825
- "egrep",
18826
- "fgrep",
18827
- "rg",
18828
- "ag",
18829
- "ack",
18830
- "find",
18831
- "fd",
18832
- "fzf",
18833
- "locate",
18834
- "which",
18835
- "whereis",
18836
- "type",
18837
- "command",
18838
- // Directory listing
18839
- "ls",
18840
- "dir",
18841
- "tree",
18842
- "exa",
18843
- "eza",
18844
- "lsd",
18845
- // Path/string utilities
18846
- "basename",
18847
- "dirname",
18848
- "realpath",
18849
- "readlink",
18850
- "echo",
18851
- "printf",
18852
- "true",
18853
- "false",
18854
- "test",
18855
- "[",
18856
- // Date/time
18857
- "date",
18858
- "cal",
18859
- // Environment info
18860
- "env",
18861
- "printenv",
18862
- "uname",
18863
- "hostname",
18864
- "whoami",
18865
- "id",
18866
- "pwd",
18867
- // Process viewing (read-only)
18868
- "ps",
18869
- "top",
18870
- "htop",
18871
- "uptime",
18872
- "free",
18873
- "df",
18874
- "du",
18875
- "lsof",
18876
- // Text processing
18877
- "sed",
18878
- "awk",
18879
- "jq",
18880
- "yq",
18881
- "xargs",
18882
- "seq",
18883
- // Pagers and formatters
18884
- "bat",
18885
- "pygmentize",
18886
- "highlight",
18887
- // Version managers (read-only)
18888
- "nvm",
18889
- "fnm",
18890
- "nvm",
18891
- "rbenv",
18892
- "pyenv",
18893
- // Misc safe
18894
- "cd",
18895
- "pushd",
18896
- "popd",
18897
- "dirs",
18898
- "hash",
18899
- "alias",
18900
- "sleep",
18901
- "wait",
18902
- "time",
18903
- "md5",
18904
- "md5sum",
18905
- "sha256sum",
18906
- "shasum",
18907
- "cksum",
18908
- "base64",
18909
- "openssl"
18910
- ],
18911
- alwaysDeny: [
18912
- "sudo",
18913
- "su",
18914
- "doas",
18915
- "mkfs",
18916
- "fdisk",
18917
- "dd",
18918
- "shutdown",
18919
- "reboot",
18920
- "halt",
18921
- "poweroff",
18922
- "iptables",
18923
- "ip6tables",
18924
- "nft",
18925
- "useradd",
18926
- "userdel",
18927
- "usermod",
18928
- "groupadd",
18929
- "groupdel",
18930
- "crontab",
18931
- "systemctl",
18932
- "service",
18933
- "launchctl"
18934
- ],
18935
- globalDeny: [
18936
- { pattern: "rm\\s+-[^\\s]*r[^\\s]*f|rm\\s+-[^\\s]*f[^\\s]*r", reason: "Recursive force delete" },
18937
- { pattern: ">\\/dev\\/sd|>\\/dev\\/nvme|>\\/dev\\/hd", reason: "Direct write to block device" },
18938
- { pattern: "chmod\\s+-R\\s+777", reason: "Recursively setting world-writable permissions" },
18939
- { pattern: ":\\(\\)\\s*\\{", reason: "Fork bomb pattern detected" }
18940
- ],
18941
- rules: [
18942
- // --- Node.js ecosystem ---
18943
- {
18944
- command: "node",
18945
- default: "ask",
18946
- argPatterns: [
18947
- { match: { anyArgMatches: ["^-e$", "^--eval", "^-p$", "^--print"] }, decision: "ask", reason: "Evaluating inline code" },
18948
- { match: { anyArgMatches: ["^--(version|help)$", "^-[vh]$"] }, decision: "allow", description: "Version/help flags" },
18949
- { match: { noArgs: true }, decision: "ask", reason: "Interactive REPL" }
18950
- ]
18951
- },
18952
- {
18953
- command: "npx",
18954
- default: "ask",
18955
- argPatterns: [
18956
- {
18957
- match: { anyArgMatches: ["^(jest|vitest|tsx|ts-node|tsc|eslint|prettier|rimraf|mkdirp|concurrently|turbo|next|nuxt|vite|astro|playwright|cypress|mocha|nyc|c8|nodemon|ts-jest|tsup|esbuild|rollup|webpack|prisma|drizzle-kit|typeorm|knex|sequelize-cli|tailwindcss|postcss|autoprefixer|lint-staged|husky|changeset|semantic-release|lerna|nx|create-react-app|create-next-app|create-vite|degit|storybook|wrangler|netlify|vercel)$"] },
18958
- decision: "allow",
18959
- description: "Well-known dev tools"
18960
- },
18961
- { match: { anyArgMatches: ["^--(version|help)$", "^-[vh]$"] }, decision: "allow", description: "Version/help flags" }
18962
- ]
18963
- },
18964
- {
18965
- command: "bunx",
18966
- default: "ask",
18967
- argPatterns: [
18968
- {
18969
- match: { anyArgMatches: ["^(jest|vitest|tsx|tsc|eslint|prettier|turbo|next|vite|astro|playwright|prisma|drizzle-kit|tsup|esbuild|tailwindcss|storybook|wrangler)$"] },
18970
- decision: "allow",
18971
- description: "Well-known dev tools"
18972
- }
18973
- ]
18974
- },
18975
- {
18976
- command: "npm",
18977
- default: "allow",
18978
- argPatterns: [
18979
- { match: { anyArgMatches: ["^(publish|unpublish|deprecate|owner|access|token|adduser|login)$"] }, decision: "ask", reason: "Registry modification" }
18980
- ]
18981
- },
18982
- { command: "pnpm", default: "allow", argPatterns: [{ match: { anyArgMatches: ["^publish$"] }, decision: "ask", reason: "Publishing" }] },
18983
- { command: "yarn", default: "allow", argPatterns: [{ match: { anyArgMatches: ["^publish$"] }, decision: "ask", reason: "Publishing" }] },
18984
- {
18985
- command: "bun",
18986
- default: "ask",
18987
- argPatterns: [
18988
- { match: { anyArgMatches: ["^(install|add|remove|run|test|build|init|create|pm|x|upgrade|link|unlink)$"] }, decision: "allow", description: "Standard bun commands" },
18989
- { match: { anyArgMatches: ["^--(version|help)$"] }, decision: "allow" }
18990
- ]
18991
- },
18992
- // --- Python ---
18993
- {
18994
- command: "python",
18995
- default: "ask",
18996
- argPatterns: [
18997
- { match: { anyArgMatches: ["^--(version|help)$", "^-V$"] }, decision: "allow" }
18998
- ]
18999
- },
19000
- {
19001
- command: "python3",
19002
- default: "ask",
19003
- argPatterns: [
19004
- { match: { anyArgMatches: ["^--(version|help)$", "^-V$"] }, decision: "allow" }
19005
- ]
19006
- },
19007
- { command: "pip", default: "allow" },
19008
- { command: "pip3", default: "allow" },
19009
- { command: "uv", default: "allow" },
19010
- { command: "pipx", default: "ask" },
19011
- // --- Git ---
19012
- {
19013
- command: "git",
19014
- default: "allow",
19015
- argPatterns: [
19016
- { match: { argsMatch: ["push\\s+--force", "push\\s+-f\\b"] }, decision: "ask", reason: "Force push can overwrite remote history" },
19017
- { match: { argsMatch: ["reset\\s+--hard"] }, decision: "ask", reason: "Hard reset discards changes" },
19018
- { match: { anyArgMatches: ["^clean$"] }, decision: "ask", reason: "git clean removes untracked files" }
19019
- ]
19020
- },
19021
- { command: "gh", default: "allow" },
19022
- // --- Build tools ---
19023
- { command: "make", default: "allow" },
19024
- { command: "cmake", default: "allow" },
19025
- { command: "cargo", default: "allow" },
19026
- { command: "go", default: "allow" },
19027
- { command: "rustup", default: "allow" },
19028
- { command: "tsc", default: "allow" },
19029
- { command: "turbo", default: "allow" },
19030
- { command: "nx", default: "allow" },
19031
- { command: "lerna", default: "allow" },
19032
- // --- Docker ---
19033
- {
19034
- command: "docker",
19035
- default: "ask",
19036
- argPatterns: [
19037
- { match: { anyArgMatches: ["^(ps|images|logs|inspect|stats|top|version|info)$"] }, decision: "allow", description: "Read-only docker commands" },
19038
- { match: { anyArgMatches: ["^(build|run|compose|exec|pull|stop|start|restart|create)$"] }, decision: "ask", reason: "Docker state-changing operation" },
19039
- { match: { anyArgMatches: ["^(system\\s+prune|container\\s+prune|image\\s+prune)$"] }, decision: "ask", reason: "Docker prune operations" }
19040
- ]
19041
- },
19042
- { command: "docker-compose", default: "ask" },
19043
- { command: "kubectl", default: "ask" },
19044
- // --- File operations ---
19045
- {
19046
- command: "rm",
19047
- default: "ask",
19048
- argPatterns: [
19049
- { match: { argsMatch: ["-[^\\s]*r"] }, decision: "ask", reason: "Recursive delete" },
19050
- { match: { argCount: { max: 3 }, not: false }, decision: "allow", description: "Deleting a small number of non-recursive files" }
19051
- ]
19052
- },
19053
- { command: "mkdir", default: "allow" },
19054
- { command: "touch", default: "allow" },
19055
- { command: "cp", default: "allow" },
19056
- { command: "mv", default: "allow" },
19057
- { command: "ln", default: "allow" },
19058
- { command: "chmod", default: "ask" },
19059
- { command: "chown", default: "ask" },
19060
- // --- Network ---
19061
- { command: "curl", default: "allow" },
19062
- { command: "wget", default: "allow" },
19063
- { command: "ssh", default: "ask" },
19064
- { command: "scp", default: "ask" },
19065
- { command: "rsync", default: "ask" },
19066
- // --- Package managers ---
19067
- { command: "brew", default: "allow" },
19068
- { command: "apt", default: "ask" },
19069
- { command: "apt-get", default: "ask" },
19070
- { command: "yum", default: "ask" },
19071
- { command: "dnf", default: "ask" },
19072
- { command: "pacman", default: "ask" },
19073
- // --- Terraform / IaC ---
19074
- { command: "terraform", default: "ask", argPatterns: [
19075
- { match: { anyArgMatches: ["^(plan|validate|fmt|show|state|output|providers|version|graph|console)$"] }, decision: "allow", description: "Read-only terraform commands" }
19076
- ] }
19077
- ]
18794
+ layers: [{
18795
+ alwaysAllow: [
18796
+ // Read-only file operations
18797
+ "cat",
18798
+ "head",
18799
+ "tail",
18800
+ "less",
18801
+ "more",
18802
+ "wc",
18803
+ "sort",
18804
+ "uniq",
18805
+ "tee",
18806
+ "diff",
18807
+ "comm",
18808
+ "cut",
18809
+ "paste",
18810
+ "tr",
18811
+ "fold",
18812
+ "expand",
18813
+ "unexpand",
18814
+ "column",
18815
+ "rev",
18816
+ "tac",
18817
+ "nl",
18818
+ "od",
18819
+ "xxd",
18820
+ "file",
18821
+ "stat",
18822
+ // Search/find
18823
+ "grep",
18824
+ "egrep",
18825
+ "fgrep",
18826
+ "rg",
18827
+ "ag",
18828
+ "ack",
18829
+ "find",
18830
+ "fd",
18831
+ "fzf",
18832
+ "locate",
18833
+ "which",
18834
+ "whereis",
18835
+ "type",
18836
+ "command",
18837
+ // Directory listing
18838
+ "ls",
18839
+ "dir",
18840
+ "tree",
18841
+ "exa",
18842
+ "eza",
18843
+ "lsd",
18844
+ // Path/string utilities
18845
+ "basename",
18846
+ "dirname",
18847
+ "realpath",
18848
+ "readlink",
18849
+ "echo",
18850
+ "printf",
18851
+ "true",
18852
+ "false",
18853
+ "test",
18854
+ "[",
18855
+ // Date/time
18856
+ "date",
18857
+ "cal",
18858
+ // Environment info
18859
+ "env",
18860
+ "printenv",
18861
+ "uname",
18862
+ "hostname",
18863
+ "whoami",
18864
+ "id",
18865
+ "pwd",
18866
+ // Process viewing (read-only)
18867
+ "ps",
18868
+ "top",
18869
+ "htop",
18870
+ "uptime",
18871
+ "free",
18872
+ "df",
18873
+ "du",
18874
+ "lsof",
18875
+ // Text processing
18876
+ "sed",
18877
+ "awk",
18878
+ "jq",
18879
+ "yq",
18880
+ "xargs",
18881
+ "seq",
18882
+ // Pagers and formatters
18883
+ "bat",
18884
+ "pygmentize",
18885
+ "highlight",
18886
+ // Version managers (read-only)
18887
+ "nvm",
18888
+ "fnm",
18889
+ "rbenv",
18890
+ "pyenv",
18891
+ // Misc safe
18892
+ "cd",
18893
+ "pushd",
18894
+ "popd",
18895
+ "dirs",
18896
+ "hash",
18897
+ "alias",
18898
+ "sleep",
18899
+ "wait",
18900
+ "time",
18901
+ "md5",
18902
+ "md5sum",
18903
+ "sha256sum",
18904
+ "shasum",
18905
+ "cksum",
18906
+ "base64",
18907
+ "openssl"
18908
+ ],
18909
+ alwaysDeny: [
18910
+ "sudo",
18911
+ "su",
18912
+ "doas",
18913
+ "mkfs",
18914
+ "fdisk",
18915
+ "dd",
18916
+ "shutdown",
18917
+ "reboot",
18918
+ "halt",
18919
+ "poweroff",
18920
+ "iptables",
18921
+ "ip6tables",
18922
+ "nft",
18923
+ "useradd",
18924
+ "userdel",
18925
+ "usermod",
18926
+ "groupadd",
18927
+ "groupdel",
18928
+ "crontab",
18929
+ "systemctl",
18930
+ "service",
18931
+ "launchctl"
18932
+ ],
18933
+ rules: [
18934
+ // --- Node.js ecosystem ---
18935
+ {
18936
+ command: "node",
18937
+ default: "ask",
18938
+ argPatterns: [
18939
+ { match: { anyArgMatches: ["^-e$", "^--eval", "^-p$", "^--print"] }, decision: "ask", reason: "Evaluating inline code" },
18940
+ { match: { anyArgMatches: ["^--(version|help)$", "^-[vh]$"] }, decision: "allow", description: "Version/help flags" },
18941
+ { match: { noArgs: true }, decision: "ask", reason: "Interactive REPL" }
18942
+ ]
18943
+ },
18944
+ {
18945
+ command: "npx",
18946
+ default: "ask",
18947
+ argPatterns: [
18948
+ {
18949
+ match: { anyArgMatches: ["^(jest|vitest|tsx|ts-node|tsc|eslint|prettier|mkdirp|concurrently|turbo|next|nuxt|vite|astro|playwright|cypress|mocha|nyc|c8|nodemon|ts-jest|tsup|esbuild|rollup|webpack|prisma|drizzle-kit|typeorm|knex|sequelize-cli|tailwindcss|postcss|autoprefixer|lint-staged|husky|changeset|semantic-release|lerna|nx|create-react-app|create-next-app|create-vite|degit|storybook|wrangler|netlify|vercel)$"] },
18950
+ decision: "allow",
18951
+ description: "Well-known dev tools"
18952
+ },
18953
+ { match: { anyArgMatches: ["^--(version|help)$", "^-[vh]$"] }, decision: "allow", description: "Version/help flags" }
18954
+ ]
18955
+ },
18956
+ {
18957
+ command: "bunx",
18958
+ default: "ask",
18959
+ argPatterns: [
18960
+ {
18961
+ match: { anyArgMatches: ["^(jest|vitest|tsx|ts-node|tsc|eslint|prettier|mkdirp|concurrently|turbo|next|nuxt|vite|astro|playwright|cypress|mocha|nyc|c8|nodemon|ts-jest|tsup|esbuild|rollup|webpack|prisma|drizzle-kit|typeorm|knex|sequelize-cli|tailwindcss|postcss|autoprefixer|lint-staged|husky|changeset|semantic-release|lerna|nx|create-react-app|create-next-app|create-vite|degit|storybook|wrangler|netlify|vercel)$"] },
18962
+ decision: "allow",
18963
+ description: "Well-known dev tools"
18964
+ },
18965
+ { match: { anyArgMatches: ["^--(version|help)$", "^-[vh]$"] }, decision: "allow", description: "Version/help flags" }
18966
+ ]
18967
+ },
18968
+ {
18969
+ command: "npm",
18970
+ default: "allow",
18971
+ argPatterns: [
18972
+ { match: { anyArgMatches: ["^(publish|unpublish|deprecate|owner|access|token|adduser|login)$"] }, decision: "ask", reason: "Registry modification" }
18973
+ ]
18974
+ },
18975
+ {
18976
+ command: "pnpm",
18977
+ default: "allow",
18978
+ argPatterns: [
18979
+ { match: { anyArgMatches: ["^(publish|unpublish|deprecate|owner|access|token|adduser|login)$"] }, decision: "ask", reason: "Registry modification" }
18980
+ ]
18981
+ },
18982
+ {
18983
+ command: "yarn",
18984
+ default: "allow",
18985
+ argPatterns: [
18986
+ { match: { anyArgMatches: ["^(publish|unpublish|owner|access|token|login|logout)$"] }, decision: "ask", reason: "Registry modification" }
18987
+ ]
18988
+ },
18989
+ {
18990
+ command: "bun",
18991
+ default: "ask",
18992
+ argPatterns: [
18993
+ { match: { anyArgMatches: ["^(install|add|remove|run|test|build|init|create|pm|x|upgrade|link|unlink)$"] }, decision: "allow", description: "Standard bun commands" },
18994
+ { match: { anyArgMatches: ["^--(version|help)$"] }, decision: "allow" }
18995
+ ]
18996
+ },
18997
+ // --- Python ---
18998
+ {
18999
+ command: "python",
19000
+ default: "ask",
19001
+ argPatterns: [
19002
+ { match: { anyArgMatches: ["^--(version|help)$", "^-V$"] }, decision: "allow" }
19003
+ ]
19004
+ },
19005
+ {
19006
+ command: "python3",
19007
+ default: "ask",
19008
+ argPatterns: [
19009
+ { match: { anyArgMatches: ["^--(version|help)$", "^-V$"] }, decision: "allow" }
19010
+ ]
19011
+ },
19012
+ { command: "pip", default: "allow" },
19013
+ { command: "pip3", default: "allow" },
19014
+ {
19015
+ command: "uv",
19016
+ default: "allow",
19017
+ argPatterns: [
19018
+ { match: { anyArgMatches: ["^publish$"] }, decision: "ask", reason: "Publishing to PyPI" }
19019
+ ]
19020
+ },
19021
+ { command: "pipx", default: "ask" },
19022
+ // --- Git ---
19023
+ {
19024
+ command: "git",
19025
+ default: "allow",
19026
+ argPatterns: [
19027
+ { match: { argsMatch: ["push\\s+--force", "push\\s+-f\\b"] }, decision: "ask", reason: "Force push can overwrite remote history" },
19028
+ { match: { argsMatch: ["reset\\s+--hard"] }, decision: "ask", reason: "Hard reset discards changes" },
19029
+ { match: { anyArgMatches: ["^clean$"] }, decision: "ask", reason: "git clean removes untracked files" }
19030
+ ]
19031
+ },
19032
+ {
19033
+ command: "gh",
19034
+ default: "allow",
19035
+ argPatterns: [
19036
+ { match: { argsMatch: ["repo\\s+delete", "repo\\s+archive"] }, decision: "ask", reason: "Destructive repo operation" }
19037
+ ]
19038
+ },
19039
+ // --- Build tools ---
19040
+ { command: "make", default: "allow" },
19041
+ { command: "cmake", default: "allow" },
19042
+ {
19043
+ command: "cargo",
19044
+ default: "allow",
19045
+ argPatterns: [
19046
+ { match: { anyArgMatches: ["^(publish|login|logout|owner|yank)$"] }, decision: "ask", reason: "Registry modification" }
19047
+ ]
19048
+ },
19049
+ {
19050
+ command: "go",
19051
+ default: "allow",
19052
+ argPatterns: [
19053
+ { match: { anyArgMatches: ["^generate$"] }, decision: "ask", reason: "go generate runs arbitrary commands" }
19054
+ ]
19055
+ },
19056
+ { command: "rustup", default: "allow" },
19057
+ { command: "tsc", default: "allow" },
19058
+ { command: "turbo", default: "allow" },
19059
+ { command: "nx", default: "allow" },
19060
+ { command: "lerna", default: "allow" },
19061
+ // --- Docker ---
19062
+ {
19063
+ command: "docker",
19064
+ default: "ask",
19065
+ argPatterns: [
19066
+ { match: { anyArgMatches: ["^(ps|images|logs|inspect|stats|top|version|info)$"] }, decision: "allow", description: "Read-only docker commands" },
19067
+ { match: { anyArgMatches: ["^(build|run|compose|exec|pull|stop|start|restart|create)$"] }, decision: "ask", reason: "Docker state-changing operation" },
19068
+ { match: { anyArgMatches: ["^(system\\s+prune|container\\s+prune|image\\s+prune)$"] }, decision: "ask", reason: "Docker prune operations" }
19069
+ ]
19070
+ },
19071
+ { command: "docker-compose", default: "ask" },
19072
+ { command: "kubectl", default: "ask" },
19073
+ // --- File operations ---
19074
+ {
19075
+ command: "rm",
19076
+ default: "ask",
19077
+ argPatterns: [
19078
+ { match: { argsMatch: ["-[^\\s]*r[^\\s]*f|-[^\\s]*f[^\\s]*r"] }, decision: "deny", reason: "Recursive force delete (rm -rf)" },
19079
+ { match: { argsMatch: ["-[^\\s]*r"] }, decision: "ask", reason: "Recursive delete" },
19080
+ { match: { argCount: { max: 3 }, not: false }, decision: "allow", description: "Deleting a small number of non-recursive files" }
19081
+ ]
19082
+ },
19083
+ { command: "mkdir", default: "allow" },
19084
+ { command: "touch", default: "allow" },
19085
+ { command: "cp", default: "allow" },
19086
+ { command: "mv", default: "allow" },
19087
+ { command: "ln", default: "allow" },
19088
+ {
19089
+ command: "chmod",
19090
+ default: "ask",
19091
+ argPatterns: [
19092
+ { match: { argsMatch: ["-R\\s+777"] }, decision: "deny", reason: "Recursively setting world-writable permissions" }
19093
+ ]
19094
+ },
19095
+ { command: "chown", default: "ask" },
19096
+ // --- Network ---
19097
+ { command: "curl", default: "allow" },
19098
+ { command: "wget", default: "allow" },
19099
+ { command: "ssh", default: "ask" },
19100
+ { command: "scp", default: "ask" },
19101
+ { command: "rsync", default: "ask" },
19102
+ // --- Package managers ---
19103
+ { command: "brew", default: "allow" },
19104
+ { command: "apt", default: "ask" },
19105
+ { command: "apt-get", default: "ask" },
19106
+ { command: "yum", default: "ask" },
19107
+ { command: "dnf", default: "ask" },
19108
+ { command: "pacman", default: "ask" },
19109
+ // --- Terraform / IaC ---
19110
+ { command: "terraform", default: "ask", argPatterns: [
19111
+ { match: { anyArgMatches: ["^(plan|validate|fmt|show|state|output|providers|version|graph|console)$"] }, decision: "allow", description: "Read-only terraform commands" }
19112
+ ] }
19113
+ ]
19114
+ }]
19078
19115
  };
19079
19116
 
19080
19117
  // src/rules.ts
@@ -19088,67 +19125,135 @@ var PROJECT_CONFIG_NAMES = [
19088
19125
  ];
19089
19126
  function loadConfig(cwd) {
19090
19127
  const config = structuredClone(DEFAULT_CONFIG);
19128
+ const defaultLayer = config.layers[0];
19129
+ let userLayer = null;
19130
+ let userRaw = null;
19091
19131
  for (const configPath of USER_CONFIG_PATHS) {
19092
- if (tryMergeConfigFile(config, configPath)) break;
19132
+ const result = tryLoadFile(configPath);
19133
+ if (result) {
19134
+ userLayer = extractLayer(result);
19135
+ userRaw = result;
19136
+ break;
19137
+ }
19093
19138
  }
19139
+ let workspaceLayer = null;
19140
+ let workspaceRaw = null;
19094
19141
  if (cwd) {
19095
19142
  for (const name of PROJECT_CONFIG_NAMES) {
19096
- if (tryMergeConfigFile(config, (0, import_path2.join)(cwd, name))) break;
19143
+ const result = tryLoadFile((0, import_path2.join)(cwd, name));
19144
+ if (result) {
19145
+ workspaceLayer = extractLayer(result);
19146
+ workspaceRaw = result;
19147
+ break;
19148
+ }
19097
19149
  }
19098
19150
  }
19151
+ config.layers = [
19152
+ ...workspaceLayer ? [workspaceLayer] : [],
19153
+ ...userLayer ? [userLayer] : [],
19154
+ defaultLayer
19155
+ ];
19156
+ if (userRaw) mergeNonLayerFields(config, userRaw);
19157
+ if (workspaceRaw) mergeNonLayerFields(config, workspaceRaw);
19099
19158
  return config;
19100
19159
  }
19101
- function tryMergeConfigFile(config, filePath) {
19102
- if (!(0, import_fs.existsSync)(filePath)) return false;
19160
+ function tryLoadFile(filePath) {
19161
+ if (!(0, import_fs.existsSync)(filePath)) return null;
19103
19162
  try {
19104
19163
  const raw = (0, import_fs.readFileSync)(filePath, "utf-8");
19105
19164
  const parsed = filePath.endsWith(".yaml") || filePath.endsWith(".yml") ? (0, import_yaml.parse)(raw) : JSON.parse(raw);
19106
19165
  if (parsed && typeof parsed === "object") {
19107
- mergeConfig(config, parsed);
19108
- return true;
19166
+ return parsed;
19109
19167
  }
19110
19168
  } catch {
19111
19169
  }
19112
- return false;
19170
+ return null;
19113
19171
  }
19114
- function mergeConfig(base, override) {
19115
- if (override.alwaysAllow) {
19116
- base.alwaysAllow = [...base.alwaysAllow || [], ...override.alwaysAllow];
19172
+ function extractLayer(raw) {
19173
+ return {
19174
+ alwaysAllow: Array.isArray(raw.alwaysAllow) ? raw.alwaysAllow : [],
19175
+ alwaysDeny: Array.isArray(raw.alwaysDeny) ? raw.alwaysDeny : [],
19176
+ rules: Array.isArray(raw.rules) ? raw.rules : []
19177
+ };
19178
+ }
19179
+ function mergeNonLayerFields(config, raw) {
19180
+ if (Array.isArray(raw.trustedSSHHosts)) {
19181
+ config.trustedSSHHosts = [...config.trustedSSHHosts || [], ...raw.trustedSSHHosts];
19117
19182
  }
19118
- if (override.alwaysDeny) {
19119
- base.alwaysDeny = [...base.alwaysDeny || [], ...override.alwaysDeny];
19183
+ if (Array.isArray(raw.trustedDockerContainers)) {
19184
+ config.trustedDockerContainers = [...config.trustedDockerContainers || [], ...raw.trustedDockerContainers];
19120
19185
  }
19121
- if (override.globalDeny) {
19122
- base.globalDeny = [...base.globalDeny || [], ...override.globalDeny];
19186
+ if (Array.isArray(raw.trustedKubectlContexts)) {
19187
+ config.trustedKubectlContexts = [...config.trustedKubectlContexts || [], ...raw.trustedKubectlContexts];
19123
19188
  }
19124
- if (override.trustedSSHHosts) {
19125
- base.trustedSSHHosts = [...base.trustedSSHHosts || [], ...override.trustedSSHHosts];
19189
+ if (Array.isArray(raw.trustedSprites)) {
19190
+ config.trustedSprites = [...config.trustedSprites || [], ...raw.trustedSprites];
19126
19191
  }
19127
- if (override.trustedDockerContainers) {
19128
- base.trustedDockerContainers = [...base.trustedDockerContainers || [], ...override.trustedDockerContainers];
19192
+ if (typeof raw.defaultDecision === "string") {
19193
+ config.defaultDecision = raw.defaultDecision;
19129
19194
  }
19130
- if (override.trustedKubectlContexts) {
19131
- base.trustedKubectlContexts = [...base.trustedKubectlContexts || [], ...override.trustedKubectlContexts];
19195
+ if (typeof raw.askOnSubshell === "boolean") {
19196
+ config.askOnSubshell = raw.askOnSubshell;
19132
19197
  }
19133
- if (override.trustedSprites) {
19134
- base.trustedSprites = [...base.trustedSprites || [], ...override.trustedSprites];
19198
+ }
19199
+
19200
+ // src/suggest.ts
19201
+ function generateAllowSnippet(details) {
19202
+ const lines = [];
19203
+ const alwaysAllowCmds = [];
19204
+ const ruleCmds = [];
19205
+ for (const d of details) {
19206
+ if (d.decision === "allow") continue;
19207
+ if (d.matchedRule === "alwaysDeny" || d.matchedRule === "default") {
19208
+ if (!alwaysAllowCmds.includes(d.command)) {
19209
+ alwaysAllowCmds.push(d.command);
19210
+ }
19211
+ } else if (d.matchedRule?.endsWith(":default") || d.matchedRule?.endsWith(":argPattern")) {
19212
+ if (!ruleCmds.includes(d.command)) {
19213
+ ruleCmds.push(d.command);
19214
+ }
19215
+ }
19135
19216
  }
19136
- if (override.defaultDecision) {
19137
- base.defaultDecision = override.defaultDecision;
19217
+ if (alwaysAllowCmds.length > 0) {
19218
+ lines.push("alwaysAllow:");
19219
+ for (const cmd of alwaysAllowCmds) {
19220
+ lines.push(` - "${cmd}"`);
19221
+ }
19138
19222
  }
19139
- if (override.askOnSubshell !== void 0) {
19140
- base.askOnSubshell = override.askOnSubshell;
19223
+ if (ruleCmds.length > 0) {
19224
+ lines.push("rules:");
19225
+ for (const cmd of ruleCmds) {
19226
+ lines.push(` - command: "${cmd}"`);
19227
+ lines.push(" default: allow");
19228
+ }
19141
19229
  }
19142
- if (override.rules) {
19143
- for (const userRule of override.rules) {
19144
- const idx = base.rules.findIndex((r) => r.command === userRule.command);
19145
- if (idx >= 0) {
19146
- base.rules[idx] = userRule;
19147
- } else {
19148
- base.rules.push(userRule);
19149
- }
19230
+ return lines.join("\n");
19231
+ }
19232
+ function formatSystemMessage(decision, rawCommand, details) {
19233
+ const header = decision === "deny" ? "[warden] Command blocked" : "[warden] Command flagged for review";
19234
+ const lines = [header, ""];
19235
+ const relevant = details.filter((d) => d.decision !== "allow");
19236
+ if (relevant.length > 0) {
19237
+ for (const d of relevant) {
19238
+ lines.push(`- \`${d.command}\`: ${d.reason}`);
19150
19239
  }
19240
+ lines.push("");
19241
+ }
19242
+ const snippet = generateAllowSnippet(details);
19243
+ if (snippet) {
19244
+ lines.push("To allow this in the future, add to your warden config:");
19245
+ lines.push("");
19246
+ lines.push("```yaml");
19247
+ lines.push(snippet);
19248
+ lines.push("```");
19249
+ lines.push("");
19250
+ lines.push("Config locations:");
19251
+ lines.push("- User-level (all projects): `~/.claude/warden.yaml`");
19252
+ lines.push("- Project-level (this project): `.claude/warden.yaml`");
19253
+ lines.push("");
19254
+ lines.push("Project config takes priority over user config.");
19151
19255
  }
19256
+ return lines.join("\n");
19152
19257
  }
19153
19258
 
19154
19259
  // src/index.ts
@@ -19174,21 +19279,27 @@ async function main() {
19174
19279
  const parsed = parseCommand(command);
19175
19280
  const result = evaluate(parsed, config);
19176
19281
  if (result.decision === "allow") {
19177
- const output = {
19282
+ const output2 = {
19178
19283
  hookSpecificOutput: {
19179
19284
  hookEventName: "PreToolUse",
19180
19285
  permissionDecision: "allow",
19181
19286
  permissionDecisionReason: `[warden] ${result.reason}`
19182
19287
  }
19183
19288
  };
19184
- process.stdout.write(JSON.stringify(output));
19289
+ process.stdout.write(JSON.stringify(output2));
19185
19290
  process.exit(0);
19186
19291
  }
19187
19292
  if (result.decision === "deny") {
19293
+ const msg2 = formatSystemMessage("deny", command, result.details);
19294
+ const output2 = { systemMessage: msg2 };
19295
+ process.stdout.write(JSON.stringify(output2));
19188
19296
  process.stderr.write(`[warden] Blocked: ${result.reason}
19189
19297
  `);
19190
19298
  process.exit(2);
19191
19299
  }
19300
+ const msg = formatSystemMessage("ask", command, result.details);
19301
+ const output = { systemMessage: msg };
19302
+ process.stdout.write(JSON.stringify(output));
19192
19303
  process.exit(0);
19193
19304
  }
19194
19305
  main().catch(() => process.exit(0));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-warden",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Smart command safety filter for Claude Code — auto-approves safe commands, blocks dangerous ones",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",