@seanmozeik/tripwire 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +16 -14
  2. package/dist/index.js +35 -0
  3. package/dist/tripwire-cli.js +3 -0
  4. package/dist/tripwire-hook.js +3 -0
  5. package/dist/tripwire-pi.js +6 -4
  6. package/dist/tripwire.js +141 -0
  7. package/dist/types/dispatch.d.ts +18 -0
  8. package/dist/types/index.d.ts +6 -0
  9. package/dist/types/lib/bash.d.ts +27 -0
  10. package/dist/types/lib/config.d.ts +110 -0
  11. package/dist/types/lib/cursor.d.ts +16 -0
  12. package/dist/types/lib/decision.d.ts +13 -0
  13. package/dist/types/lib/diff.d.ts +3 -0
  14. package/dist/types/lib/event.d.ts +45 -0
  15. package/dist/types/lib/log.d.ts +2 -0
  16. package/dist/types/lib/secrets.d.ts +41 -0
  17. package/dist/types/rules/bash-deny.d.ts +5 -0
  18. package/dist/types/rules/bash-git.d.ts +5 -0
  19. package/dist/types/rules/bash-network-install.d.ts +4 -0
  20. package/dist/types/rules/bash-redirect.d.ts +4 -0
  21. package/dist/types/rules/bash-scoped-rm.d.ts +5 -0
  22. package/dist/types/rules/bash-tar-explosion.d.ts +4 -0
  23. package/dist/types/rules/config-custom.d.ts +6 -0
  24. package/dist/types/rules/lazy-code.d.ts +4 -0
  25. package/dist/types/rules/path-protect.d.ts +12 -0
  26. package/dist/types/rules/post-secret-scrub.d.ts +12 -0
  27. package/dist/types/rules/read-protect.d.ts +4 -0
  28. package/dist/types/rules/tool-policy.d.ts +5 -0
  29. package/package.json +16 -13
  30. package/dist/tripwire +0 -0
  31. package/scripts/tripwire-cli +0 -12
  32. package/src/cli.ts +0 -271
  33. package/src/dispatch.ts +0 -562
  34. package/src/index.ts +0 -6
  35. package/src/lib/bash.ts +0 -1328
  36. package/src/lib/config.ts +0 -174
  37. package/src/lib/cursor.ts +0 -336
  38. package/src/lib/decision.ts +0 -36
  39. package/src/lib/diff.ts +0 -29
  40. package/src/lib/event.ts +0 -105
  41. package/src/lib/install.ts +0 -610
  42. package/src/lib/log.ts +0 -23
  43. package/src/lib/secrets.ts +0 -184
  44. package/src/main.ts +0 -31
  45. package/src/pi-extension.ts +0 -337
  46. package/src/rules/bash-deny.ts +0 -404
  47. package/src/rules/bash-git.ts +0 -590
  48. package/src/rules/bash-network-install.ts +0 -75
  49. package/src/rules/bash-redirect.ts +0 -91
  50. package/src/rules/bash-scoped-rm.ts +0 -84
  51. package/src/rules/bash-tar-explosion.ts +0 -77
  52. package/src/rules/config-custom.ts +0 -166
  53. package/src/rules/lazy-code.ts +0 -95
  54. package/src/rules/path-protect.ts +0 -131
  55. package/src/rules/post-secret-scrub.ts +0 -49
  56. package/src/rules/read-protect.ts +0 -57
  57. package/src/rules/tool-policy.ts +0 -54
@@ -1,404 +0,0 @@
1
- import { type Segment, UNSUPPORTED_SHELL_HEAD, hasBypass } from '../lib/bash';
2
- import { type Decision, allow, ask, deny, merge } from '../lib/decision';
3
-
4
- interface Spec {
5
- readonly rule: string;
6
- readonly action: 'deny' | 'ask';
7
- readonly message: string;
8
- // Match function evaluated against the parsed segment. Returns true when
9
- // The rule fires.
10
- readonly match: (seg: Segment, raw: string) => boolean;
11
- }
12
-
13
- const argsJoined = (seg: Segment): string => seg.tokens.slice(1).join(' ');
14
-
15
- const flagPresent = (seg: Segment, ...flags: readonly string[]): boolean => {
16
- const segmentFlags = new Set(seg.flags);
17
- return flags.some((flag) => segmentFlags.has(flag));
18
- };
19
-
20
- const SPECS: readonly Spec[] = [
21
- {
22
- rule: 'unsupported-shell-structure',
23
- action: 'deny',
24
- message:
25
- 'Tripwire cannot inspect every executable branch in this shell structure. Rewrite it as simple commands joined with `;`, `&&`, or `||` so each command can be checked.',
26
- match: (seg) => seg.head === UNSUPPORTED_SHELL_HEAD,
27
- },
28
- // ── catastrophic deletions ────────────────────────────────────────────
29
- {
30
- rule: 'rm-rf-root',
31
- action: 'deny',
32
- message:
33
- 'rm -rf / is catastrophic. If the goal is cleaning a subdirectory, scope the path inside the project (e.g. ./dist).',
34
- match: (seg) =>
35
- seg.head === 'rm' && flagPresent(seg, '-rf', '-fr', '-Rf', '-fR') && seg.tokens.includes('/'),
36
- },
37
- {
38
- rule: 'rm-rf-home',
39
- action: 'deny',
40
- message: 'rm -rf on $HOME / ~ would erase the home directory. Refuse.',
41
- match: (seg) =>
42
- seg.head === 'rm' &&
43
- flagPresent(seg, '-rf', '-fr', '-Rf', '-fR') &&
44
- seg.tokens.some((t) => /^(?<home>~|\$HOME|\$\{HOME\})$/.test(t)),
45
- },
46
- {
47
- rule: 'fork-bomb',
48
- action: 'deny',
49
- message: 'Fork bomb pattern detected. Refuse.',
50
- match: (_seg, raw) => /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;/.test(raw),
51
- },
52
- {
53
- rule: 'source-script',
54
- action: 'deny',
55
- message:
56
- '`source` / `.` executes a file in the current shell. Refuse arbitrary sourced scripts.',
57
- match: (seg) => (seg.head === 'source' || seg.head === '.') && seg.tokens.length > 1,
58
- },
59
- {
60
- rule: 'dd-raw-device',
61
- action: 'deny',
62
- message: 'dd writing to a raw block device wipes the disk. Refuse.',
63
- match: (seg) =>
64
- seg.head === 'dd' && /\bof=\/dev\/(?<type>disk|sd|nvme|rdisk)/i.test(argsJoined(seg)),
65
- },
66
- {
67
- rule: 'mkfs',
68
- action: 'deny',
69
- message: 'mkfs formats a filesystem. Refuse.',
70
- match: (seg) => /^mkfs(?<ext>\.[a-z0-9]+)?$/i.test(seg.head),
71
- },
72
- {
73
- rule: 'kill-all',
74
- action: 'deny',
75
- message: 'kill -9 -1 kills every process you own. Refuse.',
76
- match: (seg) => seg.head === 'kill' && seg.tokens.includes('-9') && seg.tokens.includes('-1'),
77
- },
78
- {
79
- rule: 'chmod-777-recursive',
80
- action: 'deny',
81
- message:
82
- 'Recursive chmod 777 makes everything world-writable. Use 755 for directories, 644 for files, scoped narrowly.',
83
- match: (seg) =>
84
- seg.head === 'chmod' && seg.flags.some((f) => f.includes('R')) && seg.tokens.includes('777'),
85
- },
86
-
87
- // ── git: detailed policy lives in bash-git.ts (smarter, supports
88
- // ── `git -C <dir>`, conventional-commit enforcement, etc.) ──────────
89
-
90
- // ── verify-skipping & signing-bypass ──────────────────────────────────
91
- {
92
- rule: 'no-verify',
93
- action: 'deny',
94
- message: '--no-verify skips git hooks. Per policy: never skip hooks. Fix the underlying issue.',
95
- match: (seg) => seg.tokens.includes('--no-verify'),
96
- },
97
- {
98
- rule: 'no-gpg-sign',
99
- action: 'deny',
100
- message: 'Bypassing GPG signing is off-limits unless explicitly requested.',
101
- match: (_seg, raw) => /--no-gpg-sign\b|-c\s+commit\.gpgsign=false/.test(raw),
102
- },
103
-
104
- // ── sudo: ask ─────────────────────────────────────────────────────────
105
- {
106
- rule: 'sudo',
107
- action: 'ask',
108
- message:
109
- 'sudo escalates privileges and is almost never needed in a coding session. If genuinely required, explain why; otherwise find a non-sudo path.',
110
- match: (seg) => seg.head === 'sudo',
111
- },
112
-
113
- // ── macOS / system destructive ────────────────────────────────────────
114
- {
115
- rule: 'shutdown',
116
- action: 'deny',
117
- message:
118
- 'shutdown / reboot / halt control the machine. Refuse — system control should be done manually.',
119
- match: (seg) => ['shutdown', 'reboot', 'halt', 'poweroff'].includes(seg.head),
120
- },
121
- {
122
- rule: 'launchctl-mutation',
123
- action: 'deny',
124
- message:
125
- 'launchctl load/unload/bootstrap/bootout/kickstart mutates system services. Refuse — surface the intent instead.',
126
- match: (seg) =>
127
- seg.head === 'launchctl' &&
128
- typeof seg.tokens[1] === 'string' &&
129
- ['load', 'unload', 'bootstrap', 'bootout', 'kickstart', 'enable', 'disable'].includes(
130
- seg.tokens[1],
131
- ),
132
- },
133
- {
134
- rule: 'defaults-write',
135
- action: 'deny',
136
- message: '`defaults write` mutates macOS preferences. Refuse — surface the intent.',
137
- match: (seg) => seg.head === 'defaults' && seg.tokens[1] === 'write',
138
- },
139
- {
140
- rule: 'csrutil',
141
- action: 'deny',
142
- message: 'csrutil controls System Integrity Protection. Refuse.',
143
- match: (seg) => seg.head === 'csrutil',
144
- },
145
- {
146
- rule: 'nvram',
147
- action: 'deny',
148
- message: 'nvram modifies firmware variables. Refuse.',
149
- match: (seg) => seg.head === 'nvram',
150
- },
151
- {
152
- rule: 'diskutil-destructive',
153
- action: 'deny',
154
- message: 'diskutil eraseDisk / reformat / partitionDisk wipes disks. Refuse.',
155
- match: (seg) =>
156
- seg.head === 'diskutil' &&
157
- typeof seg.tokens[1] === 'string' &&
158
- ['eraseDisk', 'reformat', 'partitionDisk', 'eraseVolume', 'secureErase'].includes(
159
- seg.tokens[1],
160
- ),
161
- },
162
- {
163
- rule: 'tmutil-destructive',
164
- action: 'deny',
165
- message: 'tmutil delete / disablelocal touches Time Machine. Refuse.',
166
- match: (seg) =>
167
- seg.head === 'tmutil' &&
168
- typeof seg.tokens[1] === 'string' &&
169
- ['delete', 'disablelocal'].includes(seg.tokens[1]),
170
- },
171
- {
172
- rule: 'osascript',
173
- action: 'ask',
174
- message:
175
- 'osascript runs arbitrary AppleScript and can do almost anything (move files, send emails, drive apps). Confirm with the user what you want done before running it.',
176
- match: (seg) => seg.head === 'osascript',
177
- },
178
- {
179
- rule: 'topgrade',
180
- action: 'deny',
181
- message:
182
- 'topgrade upgrades everything (brew, mas, npm globals, rust, mise). System upgrades should be done manually.',
183
- match: (seg) => seg.head === 'topgrade',
184
- },
185
- {
186
- rule: 'rsync-delete',
187
- action: 'deny',
188
- message:
189
- 'rsync --delete removes files at the destination. High blast radius — surface the intent instead.',
190
- match: (seg) => seg.head === 'rsync' && seg.flags.includes('--delete'),
191
- },
192
- {
193
- rule: 'softwareupdate-install',
194
- action: 'deny',
195
- message:
196
- '`softwareupdate --install / -i / -d` triggers macOS system updates. Refuse — system updates should be done manually.',
197
- match: (seg) =>
198
- seg.head === 'softwareupdate' &&
199
- seg.flags.some(
200
- (f) => f === '--install' || f === '-i' || f === '-d' || f.startsWith('--download'),
201
- ),
202
- },
203
- {
204
- rule: 'pmset-write',
205
- action: 'deny',
206
- message:
207
- '`pmset` (with arguments) writes power-management settings. Read-only `pmset -g` is fine; mutations need the user.',
208
- match: (seg) =>
209
- seg.head === 'pmset' &&
210
- seg.tokens.length > 1 &&
211
- !seg.tokens.includes('-g') &&
212
- !seg.tokens.includes('-G'),
213
- },
214
- {
215
- rule: 'dscl-mutate',
216
- action: 'deny',
217
- message:
218
- '`dscl . -create / -delete / -append / -change / -merge` modifies the local directory service (your user account, groups, etc.). Refuse.',
219
- match: (seg) =>
220
- seg.head === 'dscl' &&
221
- seg.tokens.some((t) =>
222
- ['-create', '-delete', '-append', '-change', '-merge', '-passwd'].includes(t),
223
- ),
224
- },
225
- {
226
- rule: 'xattr-quarantine-bypass',
227
- action: 'deny',
228
- message:
229
- "`xattr -d com.apple.quarantine` removes Gatekeeper's quarantine bit — the macOS protection against running untrusted binaries. Refuse.",
230
- match: (seg) =>
231
- seg.head === 'xattr' &&
232
- seg.tokens.includes('-d') &&
233
- seg.tokens.some((t) => t.includes('com.apple.quarantine')),
234
- },
235
- {
236
- rule: 'spctl-disable',
237
- action: 'deny',
238
- message:
239
- '`spctl --master-disable` / `--global-disable` disables Gatekeeper system-wide. Refuse.',
240
- match: (seg) =>
241
- seg.head === 'spctl' &&
242
- seg.flags.some(
243
- (f) => f === '--master-disable' || f === '--global-disable' || f === '--disable',
244
- ),
245
- },
246
- {
247
- rule: 'kextload',
248
- action: 'deny',
249
- message:
250
- 'Loading a kernel extension (`kextload`, `kmutil load`) is a system-level mutation. Refuse — the user handles this manually.',
251
- match: (seg) =>
252
- seg.head === 'kextload' ||
253
- seg.head === 'kextunload' ||
254
- (seg.head === 'kmutil' && (seg.tokens[1] === 'load' || seg.tokens[1] === 'unload')),
255
- },
256
- {
257
- rule: 'security-keychain-destructive',
258
- action: 'deny',
259
- message:
260
- '`security delete-keychain / delete-generic-password / delete-internet-password / set-keychain-settings` mutates your Keychain (where CLIs store their secrets). Refuse — Keychain should be managed manually.',
261
- match: (seg) =>
262
- seg.head === 'security' &&
263
- typeof seg.tokens[1] === 'string' &&
264
- [
265
- 'delete-keychain',
266
- 'delete-generic-password',
267
- 'delete-internet-password',
268
- 'delete-certificate',
269
- 'delete-identity',
270
- 'set-keychain-settings',
271
- 'unlock-keychain',
272
- 'lock-keychain',
273
- 'create-keychain',
274
- ].includes(seg.tokens[1]),
275
- },
276
- {
277
- rule: 'security-keychain-add-write',
278
- action: 'ask',
279
- message:
280
- '`security add-generic-password / add-internet-password / add-certificate` writes to your Keychain. Other tools manage their own entries — confirm this is the right path before adding anything else manually.',
281
- match: (seg) =>
282
- seg.head === 'security' &&
283
- typeof seg.tokens[1] === 'string' &&
284
- ['add-generic-password', 'add-internet-password', 'add-certificate'].includes(seg.tokens[1]),
285
- },
286
- {
287
- rule: 'systemsetup',
288
- action: 'deny',
289
- message:
290
- '`systemsetup -set...` writes machine-wide settings (timezone, sleep, network time, restart-on-power-failure). Refuse.',
291
- match: (seg) => seg.head === 'systemsetup' && seg.tokens.some((t) => t.startsWith('-set')),
292
- },
293
- {
294
- rule: 'scutil-set',
295
- action: 'deny',
296
- message:
297
- '`scutil --set` writes system configuration (computer name, hostname, LocalHostName). Refuse — read-only `scutil --get` is fine.',
298
- match: (seg) => seg.head === 'scutil' && seg.tokens.includes('--set'),
299
- },
300
-
301
- // ── package mutation: ask before installing/removing ──────────────────
302
- {
303
- rule: 'brew-mutation',
304
- action: 'ask',
305
- message:
306
- '`brew install/uninstall/upgrade/untap` modifies the machine globally. Confirm before running.',
307
- match: (seg) =>
308
- seg.head === 'brew' &&
309
- typeof seg.tokens[1] === 'string' &&
310
- ['install', 'uninstall', 'upgrade', 'reinstall', 'untap', 'tap'].includes(seg.tokens[1]),
311
- },
312
- {
313
- rule: 'mas-mutation',
314
- action: 'ask',
315
- message: '`mas install/uninstall` changes installed Mac App Store apps. Confirm.',
316
- match: (seg) =>
317
- seg.head === 'mas' &&
318
- typeof seg.tokens[1] === 'string' &&
319
- ['install', 'uninstall', 'purchase'].includes(seg.tokens[1]),
320
- },
321
-
322
- // ── cloud destructive ────────────────────────────────────────────────
323
- {
324
- rule: 'gh-destructive',
325
- action: 'deny',
326
- message: 'gh repo/release/issue delete is destructive on shared state. Refuse.',
327
- match: (seg) =>
328
- seg.head === 'gh' &&
329
- typeof seg.tokens[1] === 'string' &&
330
- typeof seg.tokens[2] === 'string' &&
331
- ['repo', 'release', 'issue', 'pr'].includes(seg.tokens[1]) &&
332
- ['delete', 'destroy', 'remove'].includes(seg.tokens[2]),
333
- },
334
- {
335
- rule: 'flyctl-destroy',
336
- action: 'deny',
337
- message: 'flyctl apps destroy / volumes destroy nukes deployed infra. Refuse.',
338
- match: (seg) =>
339
- seg.head === 'flyctl' && seg.tokens.some((t) => t === 'destroy' || t === 'delete'),
340
- },
341
- {
342
- rule: 'gcloud-delete',
343
- action: 'deny',
344
- message: '`gcloud ... delete` affects cloud resources. Refuse.',
345
- match: (seg) => seg.head === 'gcloud' && seg.tokens.includes('delete'),
346
- },
347
- ];
348
-
349
- // Rules in this set ignore `# tripwire-allow` on the command line. They
350
- // Are catastrophic / irreversible operations and hard policy rules that
351
- // Have no legitimate prompt-line override. If the user genuinely needs
352
- // One of these to run, they should do it in a terminal themselves.
353
- const UNBYPASSABLE_RULES: ReadonlySet<string> = new Set([
354
- 'unsupported-shell-structure',
355
- // Catastrophic / irreversible
356
- 'rm-rf-root',
357
- 'rm-rf-home',
358
- 'fork-bomb',
359
- 'dd-raw-device',
360
- 'mkfs',
361
- 'kill-all',
362
- 'diskutil-destructive',
363
- 'tmutil-destructive',
364
- // System control
365
- 'shutdown',
366
- 'csrutil',
367
- 'nvram',
368
- 'kextload',
369
- 'spctl-disable',
370
- 'xattr-quarantine-bypass',
371
- 'topgrade',
372
- 'softwareupdate-install',
373
- 'systemsetup',
374
- 'scutil-set',
375
- 'security-keychain-destructive',
376
- // Hard policy rules
377
- 'no-verify',
378
- 'no-gpg-sign',
379
- ]);
380
-
381
- const bashDeny = (segments: readonly Segment[], cmd: string): Decision => {
382
- const bypass = hasBypass(cmd);
383
- // Collect the first matching spec per segment, then return the most
384
- // Restrictive across all of them. Returning the first match outright lets
385
- // A weaker `ask` on an early segment (e.g. the outer `sudo`) shadow a
386
- // `deny` on a later unwrapped segment (e.g. the interior `shutdown`).
387
- const hits: Decision[] = [];
388
- for (const seg of segments) {
389
- for (const s of SPECS) {
390
- if (!s.match(seg, cmd)) {
391
- continue;
392
- }
393
- if (bypass && !UNBYPASSABLE_RULES.has(s.rule)) {
394
- // Caller asserted in-turn approval; honor it for this rule.
395
- continue;
396
- }
397
- hits.push(s.action === 'deny' ? deny(s.rule, s.message) : ask(s.rule, s.message));
398
- break;
399
- }
400
- }
401
- return hits.length === 0 ? allow('bash-deny') : merge(hits);
402
- };
403
-
404
- export { bashDeny, UNBYPASSABLE_RULES };