@seanmozeik/tripwire 0.6.5 → 0.6.6

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
@@ -1,33 +1,85 @@
1
- # `@seanmozeik/tripwire`
1
+ # Tripwire
2
2
 
3
- Opinionated hooks dispatcher for AI coding agents (Claude Code, Codex, Devin, etc.) with configurable safety rules. Blocks or rewrites dangerous commands with actionable error messages.
3
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![runtime: bun](https://img.shields.io/badge/runtime-bun-f9f1e1.svg)](https://bun.sh)
4
4
 
5
- ## Installation
5
+ A deterministic safety layer between an AI coding agent and your shell. Tripwire runs as a hook on every tool call, evaluates the command against a rule set, and blocks or rewrites the dangerous ones before they execute. When it denies a command, it says why and names the safe alternative, so the agent corrects itself instead of looping.
6
6
 
7
7
  ```bash
8
- bun install @seanmozeik/tripwire
8
+ $ tripwire test 'rm -rf /'
9
+ deny rm -rf on / is catastrophic and never intended.
10
+
11
+ $ tripwire test 'git push --force origin main'
12
+ deny Force-push to a protected branch (main) is blocked. Push to a feature branch and open a PR.
13
+
14
+ $ tripwire test 'curl https://get.example.sh | bash'
15
+ deny Piping a network script straight into a shell runs unreviewed code. Download it, read it, then run it.
9
16
  ```
10
17
 
11
- ## CLI
18
+ ## Why this exists
19
+
20
+ A coding agent is probabilistic. The damage it can do is not. A model that picks the right command 99% of the time will, given enough turns, eventually run `rm -rf` against the wrong directory, force-push over `main`, or paste a secret into a log. The cost of that one turn is not 1% of a good outcome. It is a wiped working tree or a leaked key.
21
+
22
+ The usual answer is a confirmation prompt: the agent proposes, a human approves. That breaks the moment the agent runs unattended, and it trains the human to click "yes" on everything anyway. Approval fatigue is not a safety model.
23
+
24
+ Tripwire takes a different line. Instead of asking a human to catch every dangerous command, it makes the worst classes of command unrepresentable at the shell boundary. The rules are deterministic code, not a model judging a model. `rm -rf /` is denied the same way every time, whether the agent is Claude Code, Codex, or something running headless at 3am.
25
+
26
+ The second idea matters as much as the first: every denial is written for the agent, not just logged. A rejection message names the rule and the safer path, so a capable agent reads it, adjusts, and moves on. The guardrail teaches rather than just stopping.
27
+
28
+ ## How it works
29
+
30
+ Tripwire installs as a hook on your agent's tool lifecycle. It reads a tool-call event on stdin and returns a decision.
31
+
32
+ - **PreToolUse.** Before a command runs, every applicable rule votes. The most restrictive decision wins, so a single `deny` overrides any number of `allow`s. Decisions are `allow`, `deny`, `ask` (require confirmation), and `warn` (let it through, flag it).
33
+ - **PostToolUse.** After a command runs, tripwire scans the output and scrubs secrets before they reach the agent's context window.
34
+
35
+ Rules are pure, synchronous functions over the parsed command. Bash commands are tokenized with a real shell parser, not regex, so `git push` matches `git push` with any arguments while leaving `git push-mirror` alone, and a destructive `rm` buried inside a wrapper command is still seen for what it is.
36
+
37
+ ## What it protects against
38
+
39
+ The defaults are opinionated but conservative. Nothing here blocks ordinary work.
40
+
41
+ **Catastrophic commands.** `rm -rf /`, fork bombs, `dd` to raw disks, and the handful of one-liners that have no safe use.
42
+
43
+ **Scoped destruction.** `rm` and `find -delete` are allowed only inside build and cache directories (`dist`, `build`, `node_modules`, `.next`, `/tmp`, and the rest). A delete anywhere else is denied with a pointer to `trash` or a graveyard tool, both recoverable.
44
+
45
+ **Git policy.** Read-only git is free. History rewriting (`rebase -i`, `filter-branch`, `commit --amend`), working-tree destruction (`reset --hard`, `clean -fd`, `checkout .`), force-push, and direct push to protected branches (`main`, `master`, `develop`, `production`, `release`) are blocked. Commits are required to use Conventional Commits format and an inline `-m` message.
46
+
47
+ **Network install scripts.** `curl … | bash` and `wget … | sh` are denied. Unreviewed code from the network does not get a shell.
48
+
49
+ **Tar bombs.** Extractions that would escape the target directory or overwrite outside it are caught before they unpack.
50
+
51
+ **Package-manager and tool policy.** Configurable nudges toward a single toolchain (for example bun over npm/pnpm/yarn) and toward modern equivalents of common utilities.
52
+
53
+ **File protection.** Reads and writes to `.env`, `.ssh/`, `*.pem`, `id_rsa*`, and similar are blocked so credentials never enter agent context.
54
+
55
+ **Secret scrubbing.** Tokens and keys in command output are redacted in the PostToolUse pass.
56
+
57
+ **Lazy-code warnings.** `TODO`, `FIXME`, and placeholder stubs in written code are flagged so half-finished work does not land silently.
58
+
59
+ Every default is configurable, and you can add your own allow and deny rules on top.
60
+
61
+ ## Install
12
62
 
13
63
  ```bash
14
- tripwire test '<command>' # Test a command
15
- tripwire test --tool=Read --path=.env # Test Read tool
16
- tripwire test --post --tool=Bash --stdout='ghp_TOKEN' # Test PostToolUse
17
-
18
- tripwire install claude # Install hooks for Claude Code
19
- tripwire install codex # Install hooks for Codex
20
- tripwire install pi # Install hooks for pi-guardrails
21
- tripwire install all # Install hooks for all agents
64
+ bun install -g @seanmozeik/tripwire
22
65
  ```
23
66
 
24
- ## Hook Configuration
67
+ This puts two binaries on your PATH: `tripwire` (the CLI) and `tripwire-hook` (the dispatcher your agent calls).
25
68
 
26
- Configure your AI agent to call `tripwire-hook` for hook events. You can do this manually, or use the `tripwire install` command to automatically configure hooks for supported agents:
69
+ ## Wiring it into an agent
27
70
 
28
- ### Claude Code
71
+ Use the installer to configure hooks automatically:
29
72
 
30
- `~/.claude/settings.json`:
73
+ ```bash
74
+ tripwire install claude # Claude Code
75
+ tripwire install codex # Codex
76
+ tripwire install pi # pi-guardrails
77
+ tripwire install all # every supported agent
78
+ ```
79
+
80
+ To wire it by hand, point the agent's hook events at `tripwire-hook`.
81
+
82
+ **Claude Code** (`~/.claude/settings.json`):
31
83
 
32
84
  ```jsonc
33
85
  {
@@ -38,21 +90,26 @@ Configure your AI agent to call `tripwire-hook` for hook events. You can do this
38
90
  }
39
91
  ```
40
92
 
41
- ### Codex
93
+ **Codex** uses the same hook format as Claude Code.
42
94
 
43
- Same as Claude Code Codex uses the same hook format.
95
+ **Devin** and other agents: configure the agent to call `tripwire-hook` on tool events.
44
96
 
45
- ### Devin
97
+ ## Testing rules
46
98
 
47
- Configure in your Devin settings to call `tripwire-hook` for tool events.
99
+ `tripwire test` evaluates a command without running it, so you can check what a rule does before trusting it in a live loop.
100
+
101
+ ```bash
102
+ tripwire test 'rm -rf /' # a bash command
103
+ tripwire test --tool=Read --path=.env # a file read
104
+ tripwire test --post --tool=Bash --stdout='ghp_TOKEN' # a PostToolUse output scan
105
+ ```
48
106
 
49
107
  ## Configuration
50
108
 
51
- Create `~/.config/tripwire/config.json` to customize behavior:
109
+ Drop a `~/.config/tripwire/config.json` to extend or adjust the defaults. Unknown keys are rejected loudly rather than ignored, so a typo fails fast instead of silently disabling a rule.
52
110
 
53
111
  ```json
54
112
  {
55
- "rtk": { "enabled": true, "path": "/opt/homebrew/bin/rtk" },
56
113
  "git": {
57
114
  "protectedBranches": ["main", "master", "develop", "production", "release"],
58
115
  "enforceConventionalCommits": true
@@ -70,64 +127,48 @@ Create `~/.config/tripwire/config.json` to customize behavior:
70
127
  }
71
128
  ```
72
129
 
73
- ### Configuration Options
74
-
75
- #### `rtk`
76
-
77
- - `enabled` (boolean, default: `false`) — Enable rtk token-saver integration
78
- - `path` (string, optional) — Path to rtk binary. If not specified, searches common locations.
79
-
80
- #### `git`
81
-
82
- - `protectedBranches` (string[], default: `["main", "master", "develop", "production", "release"]`) — Branches that require PR for push
83
- - `enforceConventionalCommits` (boolean, default: `true`) — Enforce Conventional Commits format for commit messages
130
+ ### Options
84
131
 
85
- #### `safePaths`
132
+ **`git`**
86
133
 
87
- - `relative` (string[], optional) Additional relative paths considered safe for destructive operations
88
- - `absolute` (string[], optional) Additional absolute paths considered safe for destructive operations
134
+ - `protectedBranches` (string[], default `["main", "master", "develop", "production", "release"]`): branches that cannot be pushed to directly.
135
+ - `enforceConventionalCommits` (boolean, default `true`): require Conventional Commits format for commit messages.
89
136
 
90
- Default safe paths include: `dist`, `build`, `.next`, `node_modules`, `/tmp`, `/var/tmp`, and other common build/cache directories.
137
+ **`safePaths`**
91
138
 
92
- #### `blockedCommands`
139
+ - `relative` (string[]): additional relative paths where destructive operations are allowed.
140
+ - `absolute` (string[]): additional absolute paths where destructive operations are allowed.
93
141
 
94
- Array of custom command blocks:
142
+ Built-in safe paths already cover `dist`, `build`, `.next`, `node_modules`, `/tmp`, `/var/tmp`, and other common build and cache directories.
95
143
 
96
- - `pattern` (string) Command pattern to block (uses shell parsing for matching)
97
- - `message` (string) — Error message shown when blocked
98
- - `action` (`"deny"` | `"ask"`, default: `"deny"`) — Whether to deny or ask for confirmation
99
- - `requiresFlags` (string[], optional) — Match only when every listed flag is present, including `--flag=value` form
100
- - `forbidsFlagValues` (array, optional) — Match only when each listed flag is present with one of the listed values
144
+ **`blockedCommands`** is an array of custom denials:
101
145
 
102
- #### `allowedCommands`
146
+ - `pattern` (string): the command to match, parsed as shell tokens.
147
+ - `message` (string): what the agent sees when blocked.
148
+ - `action` (`"deny"` | `"ask"`, default `"deny"`): deny outright or require confirmation.
149
+ - `requiresFlags` (string[]): match only when every listed flag is present, including `--flag=value` form.
150
+ - `forbidsFlagValues` (array): match only when each listed flag carries one of the listed values.
103
151
 
104
- Array of custom command allows (overrides blocks):
152
+ **`allowedCommands`** is an array of custom allows that override blocks. Same fields as `blockedCommands`.
105
153
 
106
- - `pattern` (string) Command pattern to allow
107
- - `message` (string) — Message shown when allowed
108
- - `requiresFlags` (string[], optional) — Same matching condition as `blockedCommands`
109
- - `forbidsFlagValues` (array, optional) — Same matching condition as `blockedCommands`
154
+ ### How command matching works
110
155
 
111
- ### Shell-Based Command Matching
156
+ Patterns are parsed with the same shell tokenizer as the rest of tripwire, so matching is structural rather than substring.
112
157
 
113
- Command patterns in `blockedCommands` and `allowedCommands` use the same shell parsing as the rest of tripwire. This means:
158
+ - `rm` matches any `rm` invocation.
159
+ - `git push` matches `git push` with any arguments.
160
+ - `gog calendar create` matches that head plus subcommand path, not every `gog` command.
161
+ - `requiresFlags: ["--attendees"]` matches `--attendees X` and `--attendees=X`.
162
+ - `forbidsFlagValues: [{ "flag": "--send-updates", "values": ["all"] }]` matches `--send-updates all` and `--send-updates=all`.
114
163
 
115
- - `rm` matches any `rm` command
116
- - `git push` matches `git push` with any arguments
117
- - `gog calendar create` matches that head + subcommand path, not every `gog` command
118
- - `requiresFlags: ["--attendees"]` matches `--attendees X` and `--attendees=X`
119
- - `forbidsFlagValues: [{ "flag": "--send-updates", "values": ["all"] }]` matches `--send-updates all` and `--send-updates=all`
120
- - Patterns are parsed using shell-quote for accurate matching
121
- - More sophisticated than simple regex
122
-
123
- Example:
164
+ A worked example, blocking calendar invites that would send email until a human has reviewed them:
124
165
 
125
166
  ```json
126
167
  {
127
168
  "blockedCommands": [
128
169
  {
129
170
  "pattern": "brew install",
130
- "message": "Use brew install with explicit version pinning",
171
+ "message": "Pin an explicit version when installing.",
131
172
  "action": "ask"
132
173
  },
133
174
  {
@@ -145,45 +186,20 @@ Example:
145
186
  }
146
187
  ```
147
188
 
148
- ## Default Behavior
149
-
150
- Tripwire comes with opinionated but reasonable defaults:
151
-
152
- ### Bash Safety
153
-
154
- - Blocks catastrophic commands: `rm -rf /`, fork bombs, `dd` to disks
155
- - Blocks macOS system mutations: `defaults write`, `launchctl`, `diskutil erase`
156
- - Blocks cloud destructive operations: `gh repo delete`, `flyctl destroy`
157
- - Scopes `rm` and `find -delete` to safe paths (build outputs, cache directories)
158
- - Blocks network install scripts: `curl | bash`, `wget | sh`
159
- - Enforces package manager policy: Bun-only, no npm/pnpm/yarn/pip
160
-
161
- ### Git Policy
162
-
163
- - Read-only operations allowed: `status`, `log`, `diff`, `fetch`, etc.
164
- - Blocks working-tree destruction: `reset --hard`, `clean -fd`, `checkout .`
165
- - Blocks history rewriting: `rebase -i`, `filter-branch`, `commit --amend`
166
- - Blocks force push and protected branch pushes
167
- - Enforces Conventional Commits format (configurable)
168
- - Requires `-m "message"` for commits (no editor mode)
169
- - Asks for confirmation on merge/rebase/cherry-pick
189
+ ## Bypassing a rule
170
190
 
171
- ### File Protection
172
-
173
- - Blocks reads/writes to `.env`, `.ssh/`, `*.pem`, `id_rsa*`, etc.
174
- - Warns on TODO/FIXME/placeholder in code (configurable)
175
- - Scrubs secrets from tool output
176
-
177
- ## Bypass
178
-
179
- Add `# tripwire-allow: <reason>` to bypass any rule:
191
+ When a blocked command is genuinely what you want, append a reason and tripwire lets it through:
180
192
 
181
193
  ```bash
182
- rm -rf /tmp/test # tripwire-allow: cleaning test directory
183
- git reset --hard HEAD~1 # tripwire-allow: undoing mistaken commit
194
+ rm -rf /tmp/test # tripwire-allow: cleaning a test directory
195
+ git reset --hard HEAD~1 # tripwire-allow: undoing a mistaken commit
184
196
  ```
185
197
 
186
- ## Library Usage
198
+ The reason is required, which keeps the bypass deliberate and leaves a trail in the command itself.
199
+
200
+ ## Library usage
201
+
202
+ The decision primitives are exported for building custom rules or embedding tripwire elsewhere:
187
203
 
188
204
  ```typescript
189
205
  import { allow, deny, ask, warn } from '@seanmozeik/tripwire';
@@ -194,11 +210,11 @@ import type { Decision, Config } from '@seanmozeik/tripwire';
194
210
 
195
211
  ```bash
196
212
  bun install
197
- bun run build # Build dist/tripwire.js and dist/tripwire-cli.js
198
- bun run check # Format + lint + typecheck
199
- bun test # Run tests
213
+ bun run build # build dist/tripwire.js and dist/tripwire-cli.js
214
+ bun run check # format, lint, typecheck
215
+ bun test
200
216
  ```
201
217
 
202
218
  ## License
203
219
 
204
- MIT
220
+ MIT.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun @bytecode @bun-cjs
3
- (function(exports, require, module, __filename, __dirname) {var S=require("path"),U=require("@effect/platform-bun"),C=globalThis.Bun,Q=require("effect"),x=require("effect/unstable/cli");var W={name:"@seanmozeik/tripwire",version:"0.6.5",description:"Opinionated hooks dispatcher for AI coding agents with configurable safety rules",license:"MIT",bin:{tripwire:"./dist/tripwire-cli.js","tripwire-hook":"./dist/tripwire.js"},files:["dist","package.json","src","README.md"],type:"module",exports:{".":"./src/index.ts"},publishConfig:{access:"public"},scripts:{build:"bun scripts/build.ts",prepublishOnly:"bun run build",check:"bun run format && bun run lint:fix && bun run typecheck",format:"oxfmt --write .",lint:"oxlint --tsconfig tsconfig.oxlint.json .","lint:fix":"oxlint --format agent --tsconfig tsconfig.oxlint.json --fix .",test:"bun test",typecheck:"tsc --noEmit"},dependencies:{"@effect/platform-bun":"^4.0.0-beta.78",effect:"^4.0.0-beta.78","shell-quote":"^1.8.4"},devDependencies:{"@types/bun":"^1.3.14","@types/shell-quote":"^1.7.5",oxfmt:"^0.53.0",oxlint:"^1.68.0","oxlint-tsgolint":"^0.23.0",typescript:"^6.0.3"},engines:{bun:">=1.0"}};var N=require("os"),z=globalThis.Bun,X="tripwire-hook",$=(b)=>{if(!b)return[[{hooks:[{type:"command",command:X}]}],!1];let w=!1,q=b.map((L)=>({hooks:L.hooks.map((D)=>{if(D.command===X||D.command.endsWith("/tripwire-hook")){if(D.command!==X)return w=!0,{...D,command:X};return D}return D})}));if(q.some((L)=>L.hooks.some((D)=>D.command===X)))return[q,!w];return[[...q,{hooks:[{type:"command",command:X}]}],!1]},v=async()=>{let b=`${N.homedir()}/.claude/settings.json`,w=z.file(b);try{let q=await w.text(),y=JSON.parse(q);y.hooks??={};let[G,L]=$(y.hooks.PreToolUse),[D,j]=$(y.hooks.PostToolUse);if(y.hooks.PreToolUse=G,y.hooks.PostToolUse=D,L&&j)return{success:!0,message:`Already configured: ${b}`};return await w.write(`${JSON.stringify(y,null,2)}
3
+ (function(exports, require, module, __filename, __dirname) {var S=require("path"),U=require("@effect/platform-bun"),C=globalThis.Bun,Q=require("effect"),x=require("effect/unstable/cli");var W={name:"@seanmozeik/tripwire",version:"0.6.6",description:"Opinionated hooks dispatcher for AI coding agents with configurable safety rules",license:"MIT",bin:{tripwire:"./dist/tripwire-cli.js","tripwire-hook":"./dist/tripwire.js"},files:["dist","package.json","src","README.md"],type:"module",exports:{".":"./src/index.ts"},publishConfig:{access:"public"},scripts:{build:"bun scripts/build.ts",prepublishOnly:"bun run build",check:"bun run format && bun run lint:fix && bun run typecheck",format:"oxfmt --write .",lint:"oxlint --tsconfig tsconfig.oxlint.json .","lint:fix":"oxlint --format agent --tsconfig tsconfig.oxlint.json --fix .",test:"bun test",typecheck:"tsc --noEmit"},dependencies:{"@effect/platform-bun":"^4.0.0-beta.78",effect:"^4.0.0-beta.78","shell-quote":"^1.8.4"},devDependencies:{"@types/bun":"^1.3.14","@types/shell-quote":"^1.7.5",oxfmt:"^0.53.0",oxlint:"^1.68.0","oxlint-tsgolint":"^0.23.0",typescript:"^6.0.3"},engines:{bun:">=1.0"}};var N=require("os"),z=globalThis.Bun,X="tripwire-hook",$=(b)=>{if(!b)return[[{hooks:[{type:"command",command:X}]}],!1];let w=!1,q=b.map((L)=>({hooks:L.hooks.map((D)=>{if(D.command===X||D.command.endsWith("/tripwire-hook")){if(D.command!==X)return w=!0,{...D,command:X};return D}return D})}));if(q.some((L)=>L.hooks.some((D)=>D.command===X)))return[q,!w];return[[...q,{hooks:[{type:"command",command:X}]}],!1]},v=async()=>{let b=`${N.homedir()}/.claude/settings.json`,w=z.file(b);try{let q=await w.text(),y=JSON.parse(q);y.hooks??={};let[G,L]=$(y.hooks.PreToolUse),[D,j]=$(y.hooks.PostToolUse);if(y.hooks.PreToolUse=G,y.hooks.PostToolUse=D,L&&j)return{success:!0,message:`Already configured: ${b}`};return await w.write(`${JSON.stringify(y,null,2)}
4
4
  `),{success:!0,message:`Updated ${b}`}}catch(q){let y=q instanceof Error?q.message:String(q);if(y.includes("No such file"))return{success:!1,message:`Config file not found: ${b}`};return{success:!1,message:`Failed to update Claude config: ${y}`}}},J=async()=>{let b=`${N.homedir()}/.pi/agent/settings.json`,w=z.file(b);try{let q=await w.text(),y=JSON.parse(q);y.hooks??={};let[G,L]=$(y.hooks.PreToolUse),[D,j]=$(y.hooks.PostToolUse);if(y.hooks.PreToolUse=G,y.hooks.PostToolUse=D,L&&j)return{success:!0,message:`Already configured: ${b}`};return await w.write(`${JSON.stringify(y,null,2)}
5
5
  `),{success:!0,message:`Updated ${b}`}}catch(q){let y=q instanceof Error?q.message:String(q);if(y.includes("No such file"))return{success:!1,message:`Config file not found: ${b}`};return{success:!1,message:`Failed to update pi config: ${y}`}}},K=async()=>{let b=`${N.homedir()}/.codex/config.toml`,w=`${N.homedir()}/.codex/hooks.json`,q=z.file(w),y=z.file(b),G=!1,L=!1;try{let D=await q.text(),j=JSON.parse(D);j.hooks??={};let[Y,V]=$(j.hooks.PreToolUse),[B,A]=$(j.hooks.PostToolUse);if(j.hooks.PreToolUse=Y,j.hooks.PostToolUse=B,!V||!A)G=!0;let Z=(R)=>{return R?.map((E)=>({hooks:E.hooks.map((M)=>{if(M.command===X&&M.timeout===void 0)return{...M,timeout:10};return M})}))??[]};if(j.hooks.PreToolUse=Z(j.hooks.PreToolUse),j.hooks.PostToolUse=Z(j.hooks.PostToolUse),G)await q.write(`${JSON.stringify(j,null,2)}
6
6
  `)}catch(D){let j=D instanceof Error?D.message:String(D);if(j.includes("No such file"))return{success:!1,message:`Config file not found: ${w}`};return{success:!1,message:`Failed to update Codex hooks.json: ${j}`}}try{let j=await y.text();if(j.includes("hooks = true"));else if(L=!0,j.includes("[features]")){let Y=j.indexOf("[features]"),V=j.indexOf(`
Binary file
package/dist/tripwire.js CHANGED
@@ -81,7 +81,7 @@ ${YA($,Z)}
81
81
  Flagged targets:
82
82
  ${G}
83
83
 
84
- If raw \`rm\` is genuinely needed, append \` # tripwire-allow: <reason>\` to the command.`)};var bn=(Q)=>Q==="-x"||Q==="-xf"||Q==="-xzf"||Q==="-xjf"||Q==="-xJf"||Q==="-xvf"||Q==="-xvzf"||Q==="-xvjf"||Q==="--extract"||/^-[xvzjJtf]+$/.test(Q),gn=(Q)=>{for(let X=0;X<Q.tokens.length;X++){let Y=Q.tokens[X];if(Y==="-C"||Y==="--directory")return Q.tokens[X+1]??null;if(Y.startsWith("--directory="))return Y.slice(12)}return null},MA=(Q)=>{return Q==="/"||/^(?<home>~|\$HOME|\$\{HOME\})$/.test(Q)},AA=(Q,X)=>{if(q1(X))return I("bash-tar-explosion");for(let Y of Q){if(Y.head!=="tar")continue;if(!(Y.flags.some(bn)||Y.tokens.includes("--extract")))continue;let $=gn(Y);if($!==null&&MA($))return m("tar-extract-to-root",`tar -x with -C ${$} can overwrite arbitrary system files. Refuse \u2014 extract to a contained directory (e.g. ./tmp/extract) and inspect before moving anything elsewhere.`)}for(let Y of Q){if(Y.head!=="unzip")continue;for(let J=0;J<Y.tokens.length;J++)if(Y.tokens[J]==="-d"){let $=Y.tokens[J+1];if($!==void 0&&MA($))return m("unzip-to-root",`unzip -d ${$} can overwrite arbitrary system files. Refuse \u2014 extract to a contained directory.`)}}return I("bash-tar-explosion")};var hn=[{rule:"use-bun-not-npm",action:"deny",message:"Use `bun` instead of `npm`. Translations: `npm install` \u2192 `bun install`, `npm install <pkg>` \u2192 `bun add <pkg>`, `npm install -D <pkg>` \u2192 `bun add -d <pkg>`, `npm run X` \u2192 `bun run X` (or `bun X` for bin scripts), `npm test` \u2192 `bun test`. If you genuinely need npm (publishing to a registry that requires it, working in a different repo), append ` # tripwire-allow: <reason>` to the command.",fires:(Q)=>Q.head==="npm"},{rule:"use-bunx-not-npx",action:"deny",message:"Use `bunx` instead of `npx`. Same usage shape, faster, no implicit npm cache.",fires:(Q)=>Q.head==="npx"},{rule:"use-bun-not-pnpm",action:"deny",message:"Use `bun` instead of `pnpm`. The user is bun-only across their repos.",fires:(Q)=>Q.head==="pnpm"},{rule:"use-bun-not-yarn",action:"deny",message:"Use `bun` instead of `yarn`. The user is bun-only.",fires:(Q)=>Q.head==="yarn"},{rule:"use-uv-not-pip",action:"deny",message:"Use `uv` instead of `pip`. Translations: `pip install <pkg>` \u2192 `uv add <pkg>` (project dependency) or `uv pip install <pkg>` (env-only escape hatch). `pip freeze` \u2192 `uv pip freeze`. `pip list` \u2192 `uv pip list`. The user is uv-only across Python repos.",fires:(Q)=>Q.head==="pip"||Q.head==="pip3"},{rule:"use-uv-sync-not-venv",action:"deny",message:"`python -m venv` creates a bare venv; use `uv sync` instead. uv sync creates the venv AND installs from pyproject.toml + uv.lock in one atomic step. To activate: `source .venv/bin/activate` after.",fires:(Q)=>(Q.head==="python"||Q.head==="python3")&&Q.tokens.includes("-m")&&Q.tokens.includes("venv")},{rule:"uv-sync-over-uv-venv",action:"deny",message:"Use `uv sync` instead of `uv venv`. `uv venv` creates an empty venv that you then have to populate; `uv sync` creates the venv AND resolves+installs from pyproject.toml + uv.lock in one step. The only reason to use `uv venv` standalone is when there is no pyproject.toml \u2014 and in that case, `uv init` first.",fires:(Q)=>Q.head==="uv"&&Q.tokens[1]==="venv"},{rule:"use-bun-patch-not-patch-package",action:"deny",message:"Use `bun patch` instead of `patch-package`. Bun has built-in patch support that integrates with bun.lock; patch-package is npm-era and produces patches in a different format.",fires:(Q)=>Q.head==="patch-package"},{rule:"consider-fd",action:"warn",message:'Consider `fd` instead of `find`. Faster, simpler syntax, respects .gitignore by default. Examples: `find . -name "*.ts"` \u2192 `fd -e ts`, `find . -type f -name "X"` \u2192 `fd -t f X`, `find PATH ...` \u2192 `fd ... PATH`. The user has both installed; either works.',fires:(Q)=>Q.head==="find"},{rule:"consider-rg",action:"warn",message:'Consider `rg` (ripgrep) instead of `grep`. Faster; recurses by default; respects .gitignore by default (use `-u`/`-uu` to include ignored/hidden files); searches the CWD when no path is given. NOT a clean flag-for-flag swap; some flags differ or are unneeded. Carry over fine: `-i`, `-n`, `-v`, `-l`, `-c`. WATCH OUT: `-r` is NOT recursive in rg \u2014 it means `--replace` (rg already recurses), so `rg -rn "PATTERN" dir/` silently parses as `--replace=n` and rewrites every match to the literal "n" while exiting 0. Drop the `-r`: `grep -rn PATTERN .` \u2192 `rg -n PATTERN`. And `grep --include`/`--exclude` become `rg -g GLOB`/`-g !GLOB`.',fires:(Q)=>Q.head==="grep"||Q.head==="egrep"||Q.head==="fgrep"},{rule:"consider-btop",action:"warn",message:"Consider `btop` instead of `top`. Better UI, more info, modern.",fires:(Q)=>Q.head==="top"},{rule:"consider-dust",action:"warn",message:"Consider `dust` instead of `du -sh`. Sorted, colorful, faster.",fires:(Q)=>Q.head==="du"},{rule:"consider-duf",action:"warn",message:"Consider `duf` instead of `df -h`. Better formatting, more readable.",fires:(Q)=>Q.head==="df"},{rule:"consider-procs",action:"warn",message:"Consider `procs` instead of `ps aux`. Better filtering and output.",fires:(Q)=>Q.head==="ps"}],wA=(Q,X)=>{if(q1(X))return I("bash-tool-policy");for(let Y of Q)for(let J of hn)if(J.fires(Y))return J.action==="deny"?m(J.rule,J.message):J6(J.rule,J.message);return I("bash-tool-policy")};var mn="If this is intentional, append ` # tripwire-allow: <reason>` to the command.",dn=new Map([["add","create"],["new","create"],["edit","update"],["set","update"],["rm","delete"],["del","delete"],["remove","delete"]]),CA=(Q)=>dn.get(Q)??Q,TA=(Q)=>{let X=Q.lastIndexOf("/");return X===-1?Q:Q.slice(X+1)},pn=(Q,X)=>Q.some((Y)=>Y===X||Y.startsWith(`${X}=`)),cn=(Q,X)=>{for(let Y=0;Y<Q.length;Y++){let J=Q[Y];if(J===X)return Q[Y+1]??"";if(J.startsWith(`${X}=`))return J.slice(X.length+1)}return null},fn=(Q)=>{let X=[],Y=Q.tokens.slice(1);for(let J=0;J<Y.length;J++){let $=Y[J];if($.startsWith("-")){if(!$.includes("=")&&Y[J+1]!==void 0&&!Y[J+1].startsWith("-"))J++;continue}X.push($)}return X},IA=(Q,X)=>{let Y=X.pattern,J=L9(Y);if(J.length===0)return!1;let $=J[0].tokens,Z=$[0];if(Z===void 0)return!1;let G=$.slice(1);for(let K of Q){if(TA(K.head)!==TA(Z))continue;if(G.length>0){let _=fn(K);if(!G.every((q,D)=>_[D]!==void 0&&CA(_[D])===CA(q)))continue}if((X.requiresFlags??[]).some((_)=>!pn(K.tokens,_)))continue;if(!(X.forbidsFlagValues??[]).every((_)=>{let V=cn(K.tokens,_.flag);return V!==null&&_.values.includes(V)}))continue;if(G.length===0&&X.requiresFlags===void 0&&X.forbidsFlagValues===void 0)return!0;return!0}return!1},jA=(Q,X,Y,J)=>{if(q1(X))return I("config-custom");for(let $ of J)if(IA(Q,$))return I("config-custom");for(let $ of Y)if(IA(Q,$)){let Z=$.message.includes("tripwire-allow")?$.message:`${$.message} ${mn}`;return $.action==="ask"?o0("config-custom",Z):m("config-custom",Z)}return I("config-custom")};var EA=require("fs"),SA=(Q,X)=>{let Y=new Set(Q.split(`
84
+ If raw \`rm\` is genuinely needed, append \` # tripwire-allow: <reason>\` to the command.`)};var bn=(Q)=>Q==="-x"||Q==="-xf"||Q==="-xzf"||Q==="-xjf"||Q==="-xJf"||Q==="-xvf"||Q==="-xvzf"||Q==="-xvjf"||Q==="--extract"||/^-[xvzjJtf]+$/.test(Q),gn=(Q)=>{for(let X=0;X<Q.tokens.length;X++){let Y=Q.tokens[X];if(Y==="-C"||Y==="--directory")return Q.tokens[X+1]??null;if(Y.startsWith("--directory="))return Y.slice(12)}return null},MA=(Q)=>{return Q==="/"||/^(?<home>~|\$HOME|\$\{HOME\})$/.test(Q)},AA=(Q,X)=>{if(q1(X))return I("bash-tar-explosion");for(let Y of Q){if(Y.head!=="tar")continue;if(!(Y.flags.some(bn)||Y.tokens.includes("--extract")))continue;let $=gn(Y);if($!==null&&MA($))return m("tar-extract-to-root",`tar -x with -C ${$} can overwrite arbitrary system files. Refuse \u2014 extract to a contained directory (e.g. ./tmp/extract) and inspect before moving anything elsewhere.`)}for(let Y of Q){if(Y.head!=="unzip")continue;for(let J=0;J<Y.tokens.length;J++)if(Y.tokens[J]==="-d"){let $=Y.tokens[J+1];if($!==void 0&&MA($))return m("unzip-to-root",`unzip -d ${$} can overwrite arbitrary system files. Refuse \u2014 extract to a contained directory.`)}}return I("bash-tar-explosion")};var hn=[{rule:"use-bun-not-npm",action:"deny",message:"Use `bun` instead of `npm`. Translations: `npm install` \u2192 `bun install`, `npm install <pkg>` \u2192 `bun add <pkg>`, `npm install -D <pkg>` \u2192 `bun add -d <pkg>`, `npm run X` \u2192 `bun run X` (or `bun X` for bin scripts), `npm test` \u2192 `bun test`. If you genuinely need npm (publishing to a registry that requires it, working in a different repo), append ` # tripwire-allow: <reason>` to the command.",fires:(Q)=>Q.head==="npm"},{rule:"use-bunx-not-npx",action:"deny",message:"Use `bunx` instead of `npx`. Same usage shape, faster, no implicit npm cache.",fires:(Q)=>Q.head==="npx"},{rule:"use-bun-not-pnpm",action:"deny",message:"Use `bun` instead of `pnpm`. The user is bun-only across their repos.",fires:(Q)=>Q.head==="pnpm"},{rule:"use-bun-not-yarn",action:"deny",message:"Use `bun` instead of `yarn`. The user is bun-only.",fires:(Q)=>Q.head==="yarn"},{rule:"use-uv-not-pip",action:"deny",message:"Use `uv` instead of `pip`. Translations: `pip install <pkg>` \u2192 `uv add <pkg>` (project dependency) or `uv pip install <pkg>` (env-only escape hatch). `pip freeze` \u2192 `uv pip freeze`. `pip list` \u2192 `uv pip list`. The user is uv-only across Python repos.",fires:(Q)=>Q.head==="pip"||Q.head==="pip3"},{rule:"use-uv-sync-not-venv",action:"deny",message:"`python -m venv` creates a bare venv; use `uv sync` instead. uv sync creates the venv AND installs from pyproject.toml + uv.lock in one atomic step. To activate: `source .venv/bin/activate` after.",fires:(Q)=>(Q.head==="python"||Q.head==="python3")&&Q.tokens.includes("-m")&&Q.tokens.includes("venv")},{rule:"uv-sync-over-uv-venv",action:"deny",message:"Use `uv sync` instead of `uv venv`. `uv venv` creates an empty venv that you then have to populate; `uv sync` creates the venv AND resolves+installs from pyproject.toml + uv.lock in one step. The only reason to use `uv venv` standalone is when there is no pyproject.toml \u2014 and in that case, `uv init` first.",fires:(Q)=>Q.head==="uv"&&Q.tokens[1]==="venv"},{rule:"use-bun-patch-not-patch-package",action:"deny",message:"Use `bun patch` instead of `patch-package`. Bun has built-in patch support that integrates with bun.lock; patch-package is npm-era and produces patches in a different format.",fires:(Q)=>Q.head==="patch-package"},{rule:"consider-fd",action:"warn",message:'Consider `fd` instead of `find`. Faster, simpler syntax, respects .gitignore by default. Examples: `find . -name "*.ts"` \u2192 `fd -e ts`, `find . -type f -name "X"` \u2192 `fd -t f X`, `find PATH ...` \u2192 `fd ... PATH`. The user has both installed; either works.',fires:(Q)=>Q.head==="find"},{rule:"consider-rg",action:"warn",message:'Consider `rg` (ripgrep) instead of `grep`. Faster; recurses by default; respects .gitignore by default (use `-u`/`-uu` to include ignored/hidden files); searches the CWD when no path is given. NOT a clean flag-for-flag swap; some flags differ or are unneeded. Carry over fine: `-i`, `-n`, `-v`, `-l`, `-c`. WATCH OUT: `-r` is NOT recursive in rg, it means `--replace` (rg already recurses), so `rg -rn "PATTERN" dir/` silently parses as `--replace=n` and rewrites every match to the literal "n" while exiting 0. Drop the `-r`: `grep -rn PATTERN .` \u2192 `rg -n PATTERN`. And `grep --include`/`--exclude` become `rg -g GLOB`/`-g !GLOB`.',fires:(Q)=>Q.head==="grep"||Q.head==="egrep"||Q.head==="fgrep"},{rule:"rg-r-is-replace",action:"warn",message:'You almost certainly made a mistake: `-r` in rg means `--replace`, NOT recursive. ripgrep is always recursive by default \u2014 there is no `-r` flag for recursion. `-rn` silently parses as `--replace=n`, rewriting every match to the literal string "n" while exiting 0 with no error. If you want to search: drop the `-r` entirely (`rg -rn PATTERN` \u2192 `rg -n PATTERN`). If you genuinely want text substitution: use `--replace` explicitly so the intent is clear.',fires:(Q)=>{if(Q.head!=="rg")return!1;return Q.flags.some((X)=>X.startsWith("-")&&!X.startsWith("--")&&X.includes("r"))}},{rule:"consider-btop",action:"warn",message:"Consider `btop` instead of `top`. Better UI, more info, modern.",fires:(Q)=>Q.head==="top"},{rule:"consider-dust",action:"warn",message:"Consider `dust` instead of `du -sh`. Sorted, colorful, faster.",fires:(Q)=>Q.head==="du"},{rule:"consider-duf",action:"warn",message:"Consider `duf` instead of `df -h`. Better formatting, more readable.",fires:(Q)=>Q.head==="df"},{rule:"consider-procs",action:"warn",message:"Consider `procs` instead of `ps aux`. Better filtering and output.",fires:(Q)=>Q.head==="ps"}],wA=(Q,X)=>{if(q1(X))return I("bash-tool-policy");for(let Y of Q)for(let J of hn)if(J.fires(Y))return J.action==="deny"?m(J.rule,J.message):J6(J.rule,J.message);return I("bash-tool-policy")};var mn="If this is intentional, append ` # tripwire-allow: <reason>` to the command.",dn=new Map([["add","create"],["new","create"],["edit","update"],["set","update"],["rm","delete"],["del","delete"],["remove","delete"]]),CA=(Q)=>dn.get(Q)??Q,TA=(Q)=>{let X=Q.lastIndexOf("/");return X===-1?Q:Q.slice(X+1)},pn=(Q,X)=>Q.some((Y)=>Y===X||Y.startsWith(`${X}=`)),cn=(Q,X)=>{for(let Y=0;Y<Q.length;Y++){let J=Q[Y];if(J===X)return Q[Y+1]??"";if(J.startsWith(`${X}=`))return J.slice(X.length+1)}return null},fn=(Q)=>{let X=[],Y=Q.tokens.slice(1);for(let J=0;J<Y.length;J++){let $=Y[J];if($.startsWith("-")){if(!$.includes("=")&&Y[J+1]!==void 0&&!Y[J+1].startsWith("-"))J++;continue}X.push($)}return X},IA=(Q,X)=>{let Y=X.pattern,J=L9(Y);if(J.length===0)return!1;let $=J[0].tokens,Z=$[0];if(Z===void 0)return!1;let G=$.slice(1);for(let K of Q){if(TA(K.head)!==TA(Z))continue;if(G.length>0){let _=fn(K);if(!G.every((q,D)=>_[D]!==void 0&&CA(_[D])===CA(q)))continue}if((X.requiresFlags??[]).some((_)=>!pn(K.tokens,_)))continue;if(!(X.forbidsFlagValues??[]).every((_)=>{let V=cn(K.tokens,_.flag);return V!==null&&_.values.includes(V)}))continue;if(G.length===0&&X.requiresFlags===void 0&&X.forbidsFlagValues===void 0)return!0;return!0}return!1},jA=(Q,X,Y,J)=>{if(q1(X))return I("config-custom");for(let $ of J)if(IA(Q,$))return I("config-custom");for(let $ of Y)if(IA(Q,$)){let Z=$.message.includes("tripwire-allow")?$.message:`${$.message} ${mn}`;return $.action==="ask"?o0("config-custom",Z):m("config-custom",Z)}return I("config-custom")};var EA=require("fs"),SA=(Q,X)=>{let Y=new Set(Q.split(`
85
85
  `).map((J)=>J.trim()).filter((J)=>J.length>0));return X.split(`
86
86
  `).filter((J)=>J.trim().length>0).filter((J)=>!Y.has(J.trim()))},xA=(Q)=>{try{return EA.readFileSync(Q,"utf8")}catch{return""}};var un=[/\bTODO\s*:/i,/\bFIXME\s*:/i,/\bXXX\s*:/i,/\bHACK\s*:/i,/\bfor now\b/i,/\bnot implemented\b/i,/\bNotImplementedError\b/,/\btemp fix\b/i,/\bfallback\b/i,/\bplaceholder\b/i,/\bbackwards?[ -]?compat(?<ibility>ibility)?\b/i,/\bfor later\b/i,/\blater on\b/i,/\bget back to\b/i,/\bI'?ll fix\b/i,/\bto be implemented\b/i,/\bnot yet (?<state>implemented|done)\b/i,/\bstubbed\b/i],ln=/\.(?<ext>ts|tsx|js|jsx|mjs|cjs|py|rs|go|rb|java|kt|swift|c|cc|cpp|h|hpp|cs|php|sh|zsh|bash|lua|ex|exs|clj|scala|dart)$/i,rn=/(?<prefix>^|\/)(?<dir>__tests__|tests?|spec|fixtures?|mocks?|__mocks__|stories)(?<suffix>\/|$)|\.(?<ext>test|spec|fixture|mock|stories)\.[^/]+$/i,on=/tripwire-allow\b/,nn=(Q)=>{if(on.test(Q))return!1;for(let X of un)if(X.test(Q))return!0;return!1},GW=(Q)=>{let X=Q.file_path;if(!ln.test(X)||rn.test(X))return I("lazy-code");let Y="content"in Q?Q.content:Q.new_string,J="content"in Q?xA(X):Q.old_string,$=[];for(let K of SA(J,Y))if(nn(K))$.push(K.slice(0,200));if($.length===0)return I("lazy-code");let Z=$.slice(0,3).map((K)=>` \u2022 ${K}`).join(`
87
87
  `),G=$.length>3?`
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanmozeik/tripwire",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "description": "Opinionated hooks dispatcher for AI coding agents with configurable safety rules",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -88,9 +88,21 @@ const POLICIES: readonly Policy[] = [
88
88
  rule: 'consider-rg',
89
89
  action: 'warn',
90
90
  message:
91
- 'Consider `rg` (ripgrep) instead of `grep`. Faster; recurses by default; respects .gitignore by default (use `-u`/`-uu` to include ignored/hidden files); searches the CWD when no path is given. NOT a clean flag-for-flag swap; some flags differ or are unneeded. Carry over fine: `-i`, `-n`, `-v`, `-l`, `-c`. WATCH OUT: `-r` is NOT recursive in rg it means `--replace` (rg already recurses), so `rg -rn "PATTERN" dir/` silently parses as `--replace=n` and rewrites every match to the literal "n" while exiting 0. Drop the `-r`: `grep -rn PATTERN .` → `rg -n PATTERN`. And `grep --include`/`--exclude` become `rg -g GLOB`/`-g !GLOB`.',
91
+ 'Consider `rg` (ripgrep) instead of `grep`. Faster; recurses by default; respects .gitignore by default (use `-u`/`-uu` to include ignored/hidden files); searches the CWD when no path is given. NOT a clean flag-for-flag swap; some flags differ or are unneeded. Carry over fine: `-i`, `-n`, `-v`, `-l`, `-c`. WATCH OUT: `-r` is NOT recursive in rg, it means `--replace` (rg already recurses), so `rg -rn "PATTERN" dir/` silently parses as `--replace=n` and rewrites every match to the literal "n" while exiting 0. Drop the `-r`: `grep -rn PATTERN .` → `rg -n PATTERN`. And `grep --include`/`--exclude` become `rg -g GLOB`/`-g !GLOB`.',
92
92
  fires: (seg) => seg.head === 'grep' || seg.head === 'egrep' || seg.head === 'fgrep',
93
93
  },
94
+ {
95
+ rule: 'rg-r-is-replace',
96
+ action: 'warn',
97
+ message:
98
+ 'You almost certainly made a mistake: `-r` in rg means `--replace`, NOT recursive. ripgrep is always recursive by default — there is no `-r` flag for recursion. `-rn` silently parses as `--replace=n`, rewriting every match to the literal string "n" while exiting 0 with no error. If you want to search: drop the `-r` entirely (`rg -rn PATTERN` → `rg -n PATTERN`). If you genuinely want text substitution: use `--replace` explicitly so the intent is clear.',
99
+ fires: (seg) => {
100
+ if (seg.head !== 'rg') {
101
+ return false;
102
+ }
103
+ return seg.flags.some((f) => f.startsWith('-') && !f.startsWith('--') && f.includes('r'));
104
+ },
105
+ },
94
106
  {
95
107
  rule: 'consider-btop',
96
108
  action: 'warn',