@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 +117 -101
- package/dist/tripwire-cli.js +1 -1
- package/dist/tripwire-cli.js.jsc +0 -0
- package/dist/tripwire.js +1 -1
- package/dist/tripwire.js.jsc +0 -0
- package/package.json +1 -1
- package/src/rules/bash-tool-policy.ts +13 -1
package/README.md
CHANGED
|
@@ -1,33 +1,85 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Tripwire
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](LICENSE) [](https://bun.sh)
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
67
|
+
This puts two binaries on your PATH: `tripwire` (the CLI) and `tripwire-hook` (the dispatcher your agent calls).
|
|
25
68
|
|
|
26
|
-
|
|
69
|
+
## Wiring it into an agent
|
|
27
70
|
|
|
28
|
-
|
|
71
|
+
Use the installer to configure hooks automatically:
|
|
29
72
|
|
|
30
|
-
|
|
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
|
-
|
|
93
|
+
**Codex** uses the same hook format as Claude Code.
|
|
42
94
|
|
|
43
|
-
|
|
95
|
+
**Devin** and other agents: configure the agent to call `tripwire-hook` on tool events.
|
|
44
96
|
|
|
45
|
-
|
|
97
|
+
## Testing rules
|
|
46
98
|
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
132
|
+
**`git`**
|
|
86
133
|
|
|
87
|
-
- `
|
|
88
|
-
- `
|
|
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
|
-
|
|
137
|
+
**`safePaths`**
|
|
91
138
|
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
+
**`allowedCommands`** is an array of custom allows that override blocks. Same fields as `blockedCommands`.
|
|
105
153
|
|
|
106
|
-
|
|
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
|
-
|
|
156
|
+
Patterns are parsed with the same shell tokenizer as the rest of tripwire, so matching is structural rather than substring.
|
|
112
157
|
|
|
113
|
-
|
|
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
|
-
|
|
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": "
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
|
183
|
-
git reset --hard HEAD~1
|
|
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
|
-
|
|
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 #
|
|
198
|
-
bun run check #
|
|
199
|
-
bun test
|
|
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.
|
package/dist/tripwire-cli.js
CHANGED
|
@@ -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.
|
|
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(`
|
package/dist/tripwire-cli.js.jsc
CHANGED
|
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
|
|
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?`
|
package/dist/tripwire.js.jsc
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -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
|
|
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',
|