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