@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,590 +0,0 @@
1
- import { type Segment, collectHeredocBodies, hasBypass, unwrapStaticString } from '../lib/bash';
2
- import type { GitConfig } from '../lib/config';
3
- import { type Decision, allow, ask, deny, warn } from '../lib/decision';
4
-
5
- // Git policy separates read operations from destructive worktree, history,
6
- // Branch, push, commit, and configuration changes. Global Git options are
7
- // Removed before subcommand dispatch so `git -C repo reset --hard` receives
8
- // The same decision as `git reset --hard`.
9
-
10
- const getProtectedBranches = (config: GitConfig): readonly string[] =>
11
- config.protectedBranches ?? [];
12
-
13
- // Conventional Commits 1.0.0 — type(scope)?(!)?: description
14
- const CONVENTIONAL_RE =
15
- /^(?<type>feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?<scope>\([\w./\- ]+\))?!?:\s+\S/;
16
-
17
- const PRE_SUB_FLAG_TAKES_VALUE: ReadonlySet<string> = new Set([
18
- '-C',
19
- '-c',
20
- '--git-dir',
21
- '--work-tree',
22
- '--namespace',
23
- '--super-prefix',
24
- ]);
25
-
26
- const PRE_SUB_FLAG_NO_VALUE: ReadonlySet<string> = new Set([
27
- '--bare',
28
- '--paginate',
29
- '-p',
30
- '--no-pager',
31
- '--no-replace-objects',
32
- '--literal-pathspecs',
33
- '--glob-pathspecs',
34
- '--noglob-pathspecs',
35
- '--icase-pathspecs',
36
- '--no-optional-locks',
37
- '--exec-path',
38
- '--html-path',
39
- '--man-path',
40
- '--info-path',
41
- ]);
42
-
43
- interface GitInvocation {
44
- readonly subcommand: string;
45
- readonly subArgs: readonly string[];
46
- }
47
-
48
- const parseGit = (seg: Segment): GitInvocation | null => {
49
- if (seg.head !== 'git') {
50
- return null;
51
- }
52
- const toks = seg.tokens.slice(1);
53
- let i = 0;
54
- while (i < toks.length) {
55
- const t = toks[i];
56
- if (t === undefined) {
57
- break;
58
- }
59
- if (PRE_SUB_FLAG_TAKES_VALUE.has(t)) {
60
- i += 2;
61
- continue;
62
- }
63
- if (PRE_SUB_FLAG_NO_VALUE.has(t)) {
64
- i += 1;
65
- continue;
66
- }
67
- if (
68
- t.startsWith('--git-dir=') ||
69
- t.startsWith('--work-tree=') ||
70
- t.startsWith('--namespace=') ||
71
- t.startsWith('--super-prefix=') ||
72
- t.startsWith('--exec-path=')
73
- ) {
74
- i += 1;
75
- continue;
76
- }
77
- if (t.startsWith('-')) {
78
- // Unknown pre-subcommand flag; assume no value, advance.
79
- i += 1;
80
- continue;
81
- }
82
- return { subcommand: t, subArgs: toks.slice(i + 1) };
83
- }
84
- return null;
85
- };
86
-
87
- const messageOf = (
88
- subArgs: readonly string[],
89
- heredocBodies?: ReadonlyMap<string, string>,
90
- ): string | null => {
91
- for (let i = 0; i < subArgs.length; i += 1) {
92
- const t = subArgs[i];
93
- if (t === undefined) {
94
- break;
95
- }
96
- if (t === '-m' || t === '--message') {
97
- const raw = subArgs[i + 1];
98
- return raw === undefined ? null : unwrapStaticString(raw, heredocBodies);
99
- }
100
- if (t.startsWith('--message=')) {
101
- return unwrapStaticString(t.slice('--message='.length), heredocBodies);
102
- }
103
- // Combined short flags like `-am`, `-ma`, `-amS` carry the message
104
- // In the next positional arg — same as `-m` alone.
105
- if (/^-[a-zA-Z]*m[a-zA-Z]*$/.test(t)) {
106
- const raw = subArgs[i + 1];
107
- return raw === undefined ? null : unwrapStaticString(raw, heredocBodies);
108
- }
109
- }
110
- return null;
111
- };
112
-
113
- const protectedBranchHit = (positional: readonly string[], config: GitConfig): string | null => {
114
- const branches = getProtectedBranches(config);
115
- for (const arg of positional) {
116
- for (const p of branches) {
117
- if (arg === p || arg.endsWith(`:${p}`) || arg.endsWith(`/${p}`)) {
118
- return p;
119
- }
120
- }
121
- }
122
- return null;
123
- };
124
-
125
- const positionalOf = (subArgs: readonly string[]): string[] =>
126
- subArgs.filter((a) => !a.startsWith('-'));
127
-
128
- const flagsOf = (subArgs: readonly string[]): string[] => subArgs.filter((a) => a.startsWith('-'));
129
-
130
- const has = (subArgs: readonly string[], ...needles: readonly string[]): boolean => {
131
- const argumentSet = new Set(subArgs);
132
- return needles.some((needle) => argumentSet.has(needle));
133
- };
134
-
135
- interface HandlerCtx {
136
- readonly subcommand: string;
137
- readonly subArgs: readonly string[];
138
- readonly flags: readonly string[];
139
- readonly positional: readonly string[];
140
- readonly config: GitConfig;
141
- readonly heredocBodies: ReadonlyMap<string, string>;
142
- }
143
-
144
- type Handler = (ctx: HandlerCtx) => Decision;
145
-
146
- const handleConfig: Handler = ({ subArgs, positional }) => {
147
- if (has(subArgs, '--global', '--system')) {
148
- return deny(
149
- 'git-config-global',
150
- 'Modifying global / system git config is off-limits — that is your personal identity. Read-only `git config --get` is fine.',
151
- );
152
- }
153
- const isRead = has(subArgs, '--get', '-l', '--list', '--get-all', '--get-regexp');
154
- if (!isRead && positional.length >= 2) {
155
- return deny(
156
- 'git-config-write',
157
- 'Local git config writes should be done explicitly. To read a value, use `git config --get <key>`.',
158
- );
159
- }
160
- return allow('bash-git');
161
- };
162
-
163
- const handleRm: Handler = ({ subArgs }) => {
164
- if (has(subArgs, '--cached')) {
165
- return allow('bash-git');
166
- }
167
- return ask(
168
- 'git-rm',
169
- '`git rm <path>` removes from the index AND the working tree. To untrack-only, use `git rm --cached <path>`. To delete the file separately, use `trash` / `rip`. Confirm intent.',
170
- );
171
- };
172
-
173
- const handleRestore: Handler = ({ subArgs, positional }) => {
174
- const stagedOnly = has(subArgs, '--staged', '-S') && !has(subArgs, '--worktree', '-W');
175
- if (stagedOnly) {
176
- return allow('bash-git');
177
- }
178
- if (positional.length > 0) {
179
- return deny(
180
- 'git-restore-discard',
181
- '`git restore <path>` discards uncommitted changes in the working tree. Refuse — `git diff <path>` to inspect first, or `git stash push <path>` to preserve.',
182
- );
183
- }
184
- return allow('bash-git');
185
- };
186
-
187
- const handleCheckout: Handler = ({ subArgs, positional }) => {
188
- if (has(subArgs, '-b', '-B')) {
189
- return allow('bash-git');
190
- }
191
- if (has(subArgs, '-f', '--force')) {
192
- return deny(
193
- 'git-checkout-force',
194
- '`git checkout -f` overwrites the working tree without preserving uncommitted changes. Refuse.',
195
- );
196
- }
197
- if (subArgs.includes('--')) {
198
- return deny(
199
- 'git-checkout-discard',
200
- '`git checkout -- <path>` discards uncommitted working-tree changes. Refuse — use `git stash push <path>` to preserve, or `git diff <path>` to inspect first.',
201
- );
202
- }
203
- const [target] = positional;
204
- if (
205
- target !== undefined &&
206
- positional.length === 1 &&
207
- (target === '.' || target.startsWith('./'))
208
- ) {
209
- return deny(
210
- 'git-checkout-discard-all',
211
- '`git checkout .` discards ALL uncommitted working-tree changes. Refuse — `git stash` to preserve, or `git diff` to inspect first.',
212
- );
213
- }
214
- return allow('bash-git');
215
- };
216
-
217
- const handleSwitch: Handler = ({ subArgs }) => {
218
- if (has(subArgs, '-f', '--force', '--discard-changes')) {
219
- return deny(
220
- 'git-switch-force',
221
- '`git switch -f / --discard-changes` throws away uncommitted working-tree changes. Refuse.',
222
- );
223
- }
224
- return allow('bash-git');
225
- };
226
-
227
- const handleReset: Handler = ({ subArgs, flags, positional }) => {
228
- if (has(subArgs, '--hard')) {
229
- return deny(
230
- 'git-reset-hard',
231
- '`git reset --hard` discards all uncommitted changes AND moves HEAD. Refuse — describe the intent in chat. If undoing a published commit, `git revert <sha>` is safer.',
232
- );
233
- }
234
- if (has(subArgs, '--keep')) {
235
- return ask(
236
- 'git-reset-keep',
237
- '`git reset --keep` resets HEAD but preserves uncommitted local changes. Confirm intent.',
238
- );
239
- }
240
- if (positional.length === 0 && flags.length === 0) {
241
- return allow('bash-git');
242
- }
243
- return ask(
244
- 'git-reset-mixed',
245
- '`git reset` moves HEAD. Confirm intent — if undoing a commit, `git revert` is usually safer.',
246
- );
247
- };
248
-
249
- const handleClean: Handler = ({ flags }) => {
250
- if (flags.some((f) => /^-[a-zA-Z]*[df]/.test(f) || f === '--force')) {
251
- return deny(
252
- 'git-clean-fd',
253
- '`git clean -fd` deletes untracked files (often your in-progress work). Refuse — inspect with `git clean -dn` (dry run) first. If genuinely needed, append ` # tripwire-allow: <reason>`.',
254
- );
255
- }
256
- return allow('bash-git');
257
- };
258
-
259
- const handleRebase: Handler = ({ subArgs, positional, config }) => {
260
- if (has(subArgs, '--abort', '--quit', '--continue', '--skip', '--edit-todo')) {
261
- return allow('bash-git');
262
- }
263
- if (has(subArgs, '-i', '--interactive')) {
264
- return deny(
265
- 'git-rebase-interactive',
266
- '`git rebase -i` rewrites history interactively. Refuse — too easy to lose commits in the agent loop. If this is genuinely required, do it manually outside the agent.',
267
- );
268
- }
269
- const [onto] = positional;
270
- const branches = getProtectedBranches(config);
271
- if (onto !== undefined && branches.includes(onto)) {
272
- return ask(
273
- 'git-rebase-onto-protected',
274
- `Rebasing onto \`${onto}\` rewrites history of the current branch. \`git merge ${onto}\` is usually safer. Confirm intent.`,
275
- );
276
- }
277
- return ask(
278
- 'git-rebase',
279
- '`git rebase` rewrites commit history. `git merge` is usually safer. Confirm intent.',
280
- );
281
- };
282
-
283
- const handleCherryPick: Handler = ({ subArgs }) => {
284
- if (has(subArgs, '--abort', '--quit', '--continue', '--skip')) {
285
- return allow('bash-git');
286
- }
287
- return ask(
288
- 'git-cherry-pick',
289
- '`git cherry-pick` applies commits onto the current branch and can create conflicts. Confirm intent.',
290
- );
291
- };
292
-
293
- const handleMerge: Handler = ({ subArgs }) => {
294
- if (has(subArgs, '--abort', '--continue', '--quit')) {
295
- return allow('bash-git');
296
- }
297
- return ask('git-merge', '`git merge <branch>` may create merge conflicts. Confirm intent.');
298
- };
299
-
300
- const handleCommit: Handler = ({ subArgs, config, heredocBodies }) => {
301
- if (has(subArgs, '--amend')) {
302
- return deny(
303
- 'git-commit-amend',
304
- '`git commit --amend` rewrites the last commit. If it has been pushed, this causes upstream divergence. Refuse — surface the intent.',
305
- );
306
- }
307
- const msg = messageOf(subArgs, heredocBodies);
308
- const hasFile = has(subArgs, '-F', '--file', '-c', '-C', '--reuse-message', '--reedit-message');
309
- const hasNoEdit = has(subArgs, '--no-edit');
310
- if (msg === null && !hasFile && !hasNoEdit) {
311
- return deny(
312
- 'git-commit-no-message',
313
- '`git commit` without `-m "..."` opens an editor and hangs the agent. Use `git commit -m "<conventional message>"`.',
314
- );
315
- }
316
- if (msg !== null && config.enforceConventionalCommits === true && !CONVENTIONAL_RE.test(msg)) {
317
- return deny(
318
- 'git-commit-non-conventional',
319
- [
320
- 'Commit message must follow Conventional Commits format:',
321
- ' `<type>(<scope>)?(!)?: <description>`',
322
- 'Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert.',
323
- 'Examples:',
324
- ' `fix(auth): handle expired token refresh`',
325
- ' `feat: add bash-git rule`',
326
- ' `chore: bump deps`',
327
- `Got: ${JSON.stringify(msg)}`,
328
- ].join('\n'),
329
- );
330
- }
331
- if (has(subArgs, '-a', '--all') || subArgs.some((t) => /^-[a-zA-Z]*a[a-zA-Z]*$/.test(t))) {
332
- return ask(
333
- 'git-commit-auto-stage',
334
- '`git commit -a / --all` auto-stages every tracked change. Explicit `git add <files>` first is usually clearer about what is being committed. Confirm.',
335
- );
336
- }
337
- return allow('bash-git');
338
- };
339
-
340
- const handlePush: Handler = ({ subArgs, flags, positional, config }) => {
341
- if (flags.some((f) => f === '--force' || f === '-f' || f.startsWith('--force-with-lease'))) {
342
- return deny(
343
- 'git-force-push',
344
- 'Force push is forbidden. If a branch needs to be reset upstream, surface the intent — there is almost always a non-force path.',
345
- );
346
- }
347
- if (has(subArgs, '--delete', '--mirror') || subArgs.some((a) => a.startsWith(':'))) {
348
- return deny(
349
- 'git-push-delete',
350
- 'Refusing to delete a remote branch via push. If genuinely needed, surface the intent.',
351
- );
352
- }
353
- const hit = protectedBranchHit(positional, config);
354
- if (hit !== null) {
355
- return deny(
356
- 'git-push-protected',
357
- `Refusing to push directly to protected branch \`${hit}\`. Open a PR instead: \`gh pr create\`.`,
358
- );
359
- }
360
- return allow('bash-git');
361
- };
362
-
363
- const handleBranch: Handler = ({ subArgs, flags, positional, config }) => {
364
- const deleteFlag = flags.find(
365
- (f) =>
366
- f === '-D' ||
367
- f === '-d' ||
368
- f === '--delete' ||
369
- /^-[a-zA-Z]*D/.test(f) ||
370
- /^-[a-zA-Z]*d/.test(f),
371
- );
372
- if (deleteFlag !== undefined) {
373
- const targets = positional;
374
- const branches = new Set(getProtectedBranches(config));
375
- const hit = targets.find((target) => branches.has(target));
376
- if (hit !== undefined) {
377
- return deny('git-branch-delete-protected', `Refusing to delete protected branch \`${hit}\`.`);
378
- }
379
- if (deleteFlag === '-D' || deleteFlag.includes('D')) {
380
- return deny(
381
- 'git-branch-force-delete',
382
- `\`git branch -D ${targets.join(' ')}\` force-deletes branches even if unmerged (potential data loss). To delete a merged branch, use \`-d\`. To force, append \` # tripwire-allow: <reason>\`.`,
383
- );
384
- }
385
- return ask(
386
- 'git-branch-delete',
387
- `\`git branch -d ${targets.join(' ')}\` deletes a branch (merged-only check). Confirm intent.`,
388
- );
389
- }
390
- if (has(subArgs, '-m', '-M', '--move')) {
391
- return ask('git-branch-move', 'Renaming a branch can confuse pushed remotes. Confirm intent.');
392
- }
393
- return allow('bash-git');
394
- };
395
-
396
- const handleTag: Handler = ({ subArgs }) => {
397
- if (has(subArgs, '-d', '--delete')) {
398
- return deny('git-tag-delete', '`git tag -d` deletes a tag. Refuse — surface intent.');
399
- }
400
- return allow('bash-git');
401
- };
402
-
403
- const handleStash: Handler = ({ subArgs }) => {
404
- const sub = subArgs[0] ?? 'push';
405
- if (sub === 'drop' || sub === 'clear') {
406
- return deny(
407
- 'git-stash-drop',
408
- `\`git stash ${sub}\` discards stashed work. Refuse — \`git stash list\` and \`git stash show\` to inspect first.`,
409
- );
410
- }
411
- return allow('bash-git');
412
- };
413
-
414
- const handleGc: Handler = ({ flags }) => {
415
- if (flags.some((f) => f.startsWith('--prune=') || f === '--aggressive')) {
416
- return deny(
417
- 'git-gc-prune',
418
- '`git gc --prune=now` / `--aggressive` destroys reflog recovery options. Refuse.',
419
- );
420
- }
421
- return allow('bash-git');
422
- };
423
-
424
- const handleRemote: Handler = ({ subArgs }) => {
425
- const [sub] = subArgs;
426
- if (sub === 'add' || sub === 'remove' || sub === 'rm' || sub === 'set-url' || sub === 'rename') {
427
- return ask(
428
- 'git-remote-mutate',
429
- `\`git remote ${sub}\` changes which remote you're pushing to. Confirm — accidentally pointing at the wrong remote is high blast-radius.`,
430
- );
431
- }
432
- return allow('bash-git');
433
- };
434
-
435
- const handleSubmoduleOrWorktree: Handler = ({ subcommand, subArgs }) => {
436
- const sub = subArgs[0] ?? '';
437
- const mutating = ['add', 'remove', 'rm', 'deinit', 'sync', 'set-url'].includes(sub);
438
- if (mutating) {
439
- return ask(
440
- 'git-submodule-worktree-mutate',
441
- `\`git ${subcommand} ${sub}\` modifies repo structure. Confirm intent.`,
442
- );
443
- }
444
- return allow('bash-git');
445
- };
446
-
447
- const HANDLERS: ReadonlyMap<string, Handler> = new Map<string, Handler>([
448
- ['config', handleConfig],
449
- ['add', () => allow('bash-git')],
450
- ['mv', () => allow('bash-git')],
451
- ['rm', handleRm],
452
- ['restore', handleRestore],
453
- ['checkout', handleCheckout],
454
- ['switch', handleSwitch],
455
- ['reset', handleReset],
456
- ['clean', handleClean],
457
- ['rebase', handleRebase],
458
- ['cherry-pick', handleCherryPick],
459
- ['merge', handleMerge],
460
- ['commit', handleCommit],
461
- ['push', handlePush],
462
- ['branch', handleBranch],
463
- ['tag', handleTag],
464
- ['stash', handleStash],
465
- [
466
- 'filter-branch',
467
- ({ subcommand }) =>
468
- deny('git-filter', `\`git ${subcommand}\` rewrites entire repo history. Refuse.`),
469
- ],
470
- [
471
- 'filter-repo',
472
- ({ subcommand }) =>
473
- deny('git-filter', `\`git ${subcommand}\` rewrites entire repo history. Refuse.`),
474
- ],
475
- ['gc', handleGc],
476
- [
477
- 'update-ref',
478
- () =>
479
- deny(
480
- 'git-update-ref',
481
- '`git update-ref` directly mutates refs and bypasses normal git operations. Refuse.',
482
- ),
483
- ],
484
- ['remote', handleRemote],
485
- ['submodule', handleSubmoduleOrWorktree],
486
- ['worktree', handleSubmoduleOrWorktree],
487
- [
488
- 'init',
489
- ({ subcommand }) =>
490
- warn(
491
- `git-${subcommand}`,
492
- `\`git ${subcommand}\` is allowed but unusual mid-session. Make sure this is what the user asked for.`,
493
- ),
494
- ],
495
- [
496
- 'clone',
497
- ({ subcommand }) =>
498
- warn(
499
- `git-${subcommand}`,
500
- `\`git ${subcommand}\` is allowed but unusual mid-session. Make sure this is what the user asked for.`,
501
- ),
502
- ],
503
- ]);
504
-
505
- const evalGit = (
506
- inv: GitInvocation,
507
- config: GitConfig,
508
- heredocBodies: ReadonlyMap<string, string>,
509
- ): Decision | null => {
510
- const { subcommand, subArgs } = inv;
511
- const flags = flagsOf(subArgs);
512
- const positional = positionalOf(subArgs);
513
-
514
- // ── read-only / inspection ───────────────────────────────────────────
515
- const READ_ONLY: ReadonlySet<string> = new Set([
516
- 'status',
517
- 'diff',
518
- 'log',
519
- 'show',
520
- 'blame',
521
- 'rev-parse',
522
- 'rev-list',
523
- 'ls-files',
524
- 'ls-tree',
525
- 'cat-file',
526
- 'reflog',
527
- 'describe',
528
- 'shortlog',
529
- 'whatchanged',
530
- 'archive',
531
- 'bundle',
532
- 'fsck',
533
- 'fetch',
534
- 'ls-remote',
535
- 'help',
536
- 'version',
537
- 'grep',
538
- 'name-rev',
539
- 'merge-base',
540
- 'symbolic-ref',
541
- 'check-ignore',
542
- 'count-objects',
543
- 'verify-commit',
544
- 'verify-tag',
545
- ]);
546
- if (READ_ONLY.has(subcommand)) {
547
- if (subcommand === 'reflog' && (subArgs[0] === 'expire' || has(subArgs, '--expire'))) {
548
- return deny(
549
- 'git-reflog-expire',
550
- "`git reflog expire` destroys git's recovery history. Refuse — surface the intent.",
551
- );
552
- }
553
- if (subcommand === 'symbolic-ref' && positional.length >= 2) {
554
- return deny(
555
- 'git-symbolic-ref-write',
556
- '`git symbolic-ref <name> <ref>` rewrites a symbolic ref. Refuse.',
557
- );
558
- }
559
- return allow('bash-git');
560
- }
561
-
562
- const handler = HANDLERS.get(subcommand);
563
- if (handler !== undefined) {
564
- return handler({ subcommand, subArgs, flags, positional, config, heredocBodies });
565
- }
566
- return warn(
567
- 'git-unknown-subcommand',
568
- `\`git ${subcommand}\` is not classified by tripwire. Allowing — flag if this looks like history-rewriting or data-loss territory.`,
569
- );
570
- };
571
-
572
- const bashGit = (segments: readonly Segment[], cmd: string, config: GitConfig): Decision => {
573
- if (hasBypass(cmd)) {
574
- return allow('bash-git');
575
- }
576
- const heredocBodies = collectHeredocBodies(cmd);
577
- for (const seg of segments) {
578
- const inv = parseGit(seg);
579
- if (inv === null) {
580
- continue;
581
- }
582
- const d = evalGit(inv, config, heredocBodies);
583
- if (d !== null && d.kind !== 'allow') {
584
- return d;
585
- }
586
- }
587
- return allow('bash-git');
588
- };
589
-
590
- export { bashGit };
@@ -1,75 +0,0 @@
1
- import { type Segment, hasBypass } from '../lib/bash';
2
- import { type Decision, allow, ask, deny } from '../lib/decision';
3
-
4
- // Block `curl|wget ... | bash|sh|zsh` (the canonical supply-chain footgun).
5
- // Ask before global installs that pull arbitrary code from a registry.
6
-
7
- const FETCH_HEADS: ReadonlySet<string> = new Set(['curl', 'wget', 'wget2', 'aria2c', 'xh']);
8
- const SHELL_HEADS: ReadonlySet<string> = new Set(['bash', 'sh', 'zsh', 'fish']);
9
-
10
- const isFetchPipedToShell = (segments: readonly Segment[]): boolean => {
11
- // Shell-quote splits a pipeline `curl X | bash` into two segments. We
12
- // Detect the pattern by looking for adjacent fetch-then-shell heads.
13
- for (let i = 0; i < segments.length - 1; i += 1) {
14
- const a = segments[i];
15
- const b = segments[i + 1];
16
- if (a === undefined || b === undefined) {
17
- continue;
18
- }
19
- if (FETCH_HEADS.has(a.head) && SHELL_HEADS.has(b.head)) {
20
- return true;
21
- }
22
- }
23
- return false;
24
- };
25
-
26
- interface InstallSpec {
27
- readonly head: string;
28
- readonly subcommand: string;
29
- readonly rule: string;
30
- readonly message: string;
31
- }
32
-
33
- const INSTALL_SPECS: readonly InstallSpec[] = [
34
- {
35
- head: 'cargo',
36
- subcommand: 'install',
37
- rule: 'cargo-install',
38
- message:
39
- 'Confirm before `cargo install <crate>`: this builds and installs arbitrary code from crates.io into ~/.cargo/bin globally.',
40
- },
41
- {
42
- head: 'go',
43
- subcommand: 'install',
44
- rule: 'go-install',
45
- message: 'Confirm before `go install`: this fetches and installs arbitrary Go code globally.',
46
- },
47
- {
48
- head: 'gem',
49
- subcommand: 'install',
50
- rule: 'gem-install',
51
- message: 'Confirm before `gem install`: pulls arbitrary code from rubygems.org.',
52
- },
53
- ];
54
-
55
- const bashNetworkInstall = (segments: readonly Segment[], cmd: string): Decision => {
56
- if (hasBypass(cmd)) {
57
- return allow('bash-network-install');
58
- }
59
- if (isFetchPipedToShell(segments)) {
60
- return deny(
61
- 'curl-pipe-shell',
62
- "Piping `curl` / `wget` directly into a shell runs whatever the remote URL serves. Refuse — download to a file, inspect, then run if appropriate. If you genuinely need this, append ` # tripwire-allow: <reason>` (and explain to the user what you're running).",
63
- );
64
- }
65
- for (const seg of segments) {
66
- for (const s of INSTALL_SPECS) {
67
- if (seg.head === s.head && seg.tokens[1] === s.subcommand) {
68
- return ask(s.rule, s.message);
69
- }
70
- }
71
- }
72
- return allow('bash-network-install');
73
- };
74
-
75
- export { bashNetworkInstall };