@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.
@@ -0,0 +1,106 @@
1
+ import { Schema } from 'effect';
2
+
3
+ const HookEvent = Schema.Struct({
4
+ hook_event_name: Schema.String,
5
+ tool_name: Schema.optional(Schema.String),
6
+ tool_input: Schema.optional(Schema.Unknown),
7
+ tool_response: Schema.optional(Schema.Unknown),
8
+ cwd: Schema.optional(Schema.String),
9
+ session_id: Schema.optional(Schema.String),
10
+ // Codex extension: present on every PreToolUse / PostToolUse event.
11
+ turn_id: Schema.optional(Schema.String),
12
+ tool_use_id: Schema.optional(Schema.String),
13
+ });
14
+ type HookEventType = typeof HookEvent.Type;
15
+
16
+ interface BashInput {
17
+ readonly command: string;
18
+ }
19
+
20
+ interface EditInput {
21
+ readonly file_path: string;
22
+ readonly old_string: string;
23
+ readonly new_string: string;
24
+ }
25
+
26
+ interface WriteInput {
27
+ readonly file_path: string;
28
+ readonly content: string;
29
+ }
30
+
31
+ interface ReadInput {
32
+ readonly file_path: string;
33
+ }
34
+
35
+ // PostToolUse `tool_response` shape varies by tool. Bash returns
36
+ // Stdout/stderr/interrupted; Read returns content; others vary. We extract
37
+ // Any string-ish payload we can find for scanning purposes.
38
+ interface BashResponse {
39
+ readonly stdout?: string;
40
+ readonly stderr?: string;
41
+ readonly interrupted?: boolean;
42
+ }
43
+
44
+ interface ReadResponse {
45
+ readonly content?: string;
46
+ readonly file?: { readonly content?: string };
47
+ }
48
+
49
+ const isBashInput = (x: unknown): x is BashInput =>
50
+ typeof x === 'object' && x !== null && typeof (x as BashInput).command === 'string';
51
+
52
+ const isEditInput = (x: unknown): x is EditInput =>
53
+ typeof x === 'object' &&
54
+ x !== null &&
55
+ typeof (x as EditInput).file_path === 'string' &&
56
+ typeof (x as EditInput).old_string === 'string' &&
57
+ typeof (x as EditInput).new_string === 'string';
58
+
59
+ const isWriteInput = (x: unknown): x is WriteInput =>
60
+ typeof x === 'object' &&
61
+ x !== null &&
62
+ typeof (x as WriteInput).file_path === 'string' &&
63
+ typeof (x as WriteInput).content === 'string';
64
+
65
+ const isReadInput = (x: unknown): x is ReadInput =>
66
+ typeof x === 'object' && x !== null && typeof (x as ReadInput).file_path === 'string';
67
+
68
+ // Extract any string payload from a tool_response we can scan for secrets.
69
+ // Returns concatenated stdout/stderr for Bash, content for Read, or '' if
70
+ // Nothing is recognizable.
71
+ const extractResponseText = (toolName: string, response: unknown): string => {
72
+ if (typeof response !== 'object' || response === null) {
73
+ return '';
74
+ }
75
+ if (toolName === 'Bash') {
76
+ const r = response as BashResponse;
77
+ return [r.stdout ?? '', r.stderr ?? ''].filter((s) => s.length > 0).join('\n');
78
+ }
79
+ if (toolName === 'Read') {
80
+ const r = response as ReadResponse;
81
+ return r.content ?? r.file?.content ?? '';
82
+ }
83
+ // Best-effort fallback: stringify and let the scanner do its thing.
84
+ if (typeof (response as { content?: string }).content === 'string') {
85
+ return (response as { content?: string }).content ?? '';
86
+ }
87
+ return '';
88
+ };
89
+
90
+ export type {
91
+ BashInput,
92
+ BashResponse,
93
+ EditInput,
94
+ HookEventType as HookEvent,
95
+ ReadInput,
96
+ ReadResponse,
97
+ WriteInput,
98
+ };
99
+ export {
100
+ HookEvent as HookEventSchema,
101
+ extractResponseText,
102
+ isBashInput,
103
+ isEditInput,
104
+ isReadInput,
105
+ isWriteInput,
106
+ };
package/src/lib/log.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { appendFileSync, mkdirSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname } from 'node:path';
4
+
5
+ const LOG_PATH = `${homedir()}/.claude/tripwire.log`;
6
+
7
+ try {
8
+ mkdirSync(dirname(LOG_PATH), { recursive: true });
9
+ } catch {
10
+ // Directory creation failure is non-fatal — logging is best-effort.
11
+ }
12
+
13
+ const logError = (rule: string, err: unknown): void => {
14
+ const stamp = new Date().toISOString();
15
+ const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
16
+ try {
17
+ appendFileSync(LOG_PATH, `[${stamp}] [${rule}] ${msg}\n`);
18
+ } catch {
19
+ // Never block the agent on a logging failure.
20
+ }
21
+ };
22
+
23
+ export { logError };
package/src/lib/rtk.ts ADDED
@@ -0,0 +1,96 @@
1
+ // Wrap the `rtk hook claude` subprocess. After tripwire's gate passes on a
2
+ // Bash tool call, we hand the original event to rtk to apply its
3
+ // Command-rewrite logic (token-saver). If rtk returns an updatedInput, we
4
+ // Merge that into our hook response.
5
+
6
+ import { spawnSync } from 'node:child_process';
7
+
8
+ import type { RtkConfig } from './config';
9
+
10
+ interface RtkOutput {
11
+ readonly updatedCommand?: string;
12
+ readonly reason?: string;
13
+ }
14
+
15
+ const findRtkBin = (config: RtkConfig): string | null => {
16
+ // If config specifies a path, try it first
17
+ if (config.path !== undefined) {
18
+ return config.path;
19
+ }
20
+
21
+ // Try common locations
22
+ const home = process.env['HOME'] ?? '';
23
+ const commonPaths = ['/opt/homebrew/bin/rtk', '/usr/local/bin/rtk', `${home}/.local/bin/rtk`];
24
+
25
+ for (const path of commonPaths) {
26
+ try {
27
+ spawnSync('test', ['-x', path], { stdio: 'ignore' });
28
+ return path;
29
+ } catch {
30
+ continue;
31
+ }
32
+ }
33
+
34
+ // Try searching PATH
35
+ try {
36
+ const whichResult = spawnSync('which', ['rtk'], { stdio: 'pipe' });
37
+ if (whichResult.status === 0) {
38
+ const stdout = whichResult.stdout as string | Buffer | null;
39
+ if (stdout !== null) {
40
+ const path = String(stdout).trim();
41
+ if (path.length > 0) {
42
+ return path;
43
+ }
44
+ }
45
+ }
46
+ } catch {
47
+ // Ignore errors
48
+ }
49
+
50
+ return null;
51
+ };
52
+
53
+ const runRtkRewrite = (event: unknown, config: RtkConfig, timeoutMs = 2000): RtkOutput => {
54
+ // If rtk is disabled, skip it
55
+ if (!config.enabled) {
56
+ return {};
57
+ }
58
+
59
+ const rtkBin = findRtkBin(config);
60
+ if (rtkBin === null) {
61
+ // Rtk not found, silently continue
62
+ return {};
63
+ }
64
+
65
+ const payload = JSON.stringify(event);
66
+ const result = spawnSync(rtkBin, ['hook', 'claude'], {
67
+ input: payload,
68
+ encoding: 'utf8',
69
+ timeout: timeoutMs,
70
+ maxBuffer: 1024 * 1024,
71
+ });
72
+ if (result.error !== undefined || typeof result.stdout !== 'string') {
73
+ return {};
74
+ }
75
+ try {
76
+ const parsed = JSON.parse(result.stdout) as {
77
+ hookSpecificOutput?: {
78
+ permissionDecisionReason?: string;
79
+ updatedInput?: { command?: string };
80
+ };
81
+ };
82
+ const cmd = parsed.hookSpecificOutput?.updatedInput?.command;
83
+ const reason = parsed.hookSpecificOutput?.permissionDecisionReason;
84
+ if (typeof cmd !== 'string') {
85
+ return {};
86
+ }
87
+ if (typeof reason === 'string') {
88
+ return { updatedCommand: cmd, reason };
89
+ }
90
+ return { updatedCommand: cmd };
91
+ } catch {
92
+ return {};
93
+ }
94
+ };
95
+
96
+ export { runRtkRewrite };
@@ -0,0 +1,120 @@
1
+ // Secret scanning via the `betterleaks` binary (Zach Rice's gitleaks
2
+ // Successor, MIT). We spawn it once per PostToolUse, write the tool
3
+ // Output to a temp file, scan the file, parse JSON findings, redact
4
+ // Matches in-place, and delete the temp file.
5
+ //
6
+ // Why subprocess vs. inline regex: betterleaks ships the curated
7
+ // 100+-rule pack the gitleaks ecosystem has tuned over years (AWS, GH,
8
+ // Stripe, OpenAI, Anthropic, mongo URLs, JWTs, private keys, plus ~70
9
+ // Long-tail vendors). We get all of it for one fork+exec, ~250–300ms.
10
+ //
11
+ // Why a temp file vs. `--pipe`: betterleaks `--pipe` *adds* stdin to its
12
+ // Scan but does not replace the directory walk, so it scans the cwd as
13
+ // Well. Writing to a tempfile in /tmp and using `--source <file>` is
14
+ // Scoped, deterministic, and only ~5ms slower.
15
+
16
+ import { spawnSync } from 'node:child_process';
17
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+
21
+ interface BetterleaksFinding {
22
+ readonly RuleID: string;
23
+ readonly Description: string;
24
+ readonly StartLine: number;
25
+ readonly EndLine: number;
26
+ readonly Secret: string;
27
+ readonly Match: string;
28
+ }
29
+
30
+ interface ScanResult {
31
+ readonly hits: readonly { readonly rule: string; readonly count: number }[];
32
+ readonly redacted: string;
33
+ }
34
+
35
+ const BETTERLEAKS_BIN = '/opt/homebrew/bin/betterleaks';
36
+
37
+ const summarizeHits = (
38
+ findings: readonly BetterleaksFinding[],
39
+ ): readonly { rule: string; count: number }[] => {
40
+ const counts = new Map<string, number>();
41
+ for (const f of findings) {
42
+ counts.set(f.RuleID, (counts.get(f.RuleID) ?? 0) + 1);
43
+ }
44
+ return [...counts.entries()].map(([rule, count]) => ({ rule, count }));
45
+ };
46
+
47
+ const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
48
+
49
+ // Replace every found secret in the original input with a tagged redaction.
50
+ const redactWith = (input: string, findings: readonly BetterleaksFinding[]): string => {
51
+ let out = input;
52
+ // Sort by length descending so shorter matches that are substrings of
53
+ // Longer ones don't fire first and break the longer match.
54
+ const sorted = [...findings].toSorted((a, b) => b.Secret.length - a.Secret.length);
55
+ for (const f of sorted) {
56
+ if (f.Secret === '') {
57
+ continue;
58
+ }
59
+ out = out.replaceAll(f.Secret, `[REDACTED:${f.RuleID}]`);
60
+ }
61
+ return out;
62
+ };
63
+
64
+ const scanAndRedact = (input: string, timeoutMs = 5000): ScanResult => {
65
+ if (input.length === 0) {
66
+ return { hits: [], redacted: input };
67
+ }
68
+ const dir = mkdtempSync(join(tmpdir(), 'tripwire-scan-'));
69
+ const inPath = join(dir, 'input');
70
+ const reportPath = join(dir, 'report.json');
71
+ try {
72
+ writeFileSync(inPath, input);
73
+ const result = spawnSync(
74
+ BETTERLEAKS_BIN,
75
+ [
76
+ 'detect',
77
+ '--no-git',
78
+ '--no-banner',
79
+ '--no-color',
80
+ '--report-format',
81
+ 'json',
82
+ '--report-path',
83
+ reportPath,
84
+ '--source',
85
+ inPath,
86
+ '--exit-code',
87
+ '0',
88
+ '--log-level',
89
+ 'error',
90
+ ],
91
+ { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 },
92
+ );
93
+ if (result.error !== undefined) {
94
+ return { hits: [], redacted: input };
95
+ }
96
+ let findings: BetterleaksFinding[];
97
+ try {
98
+ const raw = readFileSync(reportPath, 'utf8');
99
+ const parsed = JSON.parse(raw || '[]') as unknown;
100
+ findings = Array.isArray(parsed) ? (parsed as BetterleaksFinding[]) : [];
101
+ } catch {
102
+ return { hits: [], redacted: input };
103
+ }
104
+ if (findings.length === 0) {
105
+ return { hits: [], redacted: input };
106
+ }
107
+ return { hits: summarizeHits(findings), redacted: redactWith(input, findings) };
108
+ } finally {
109
+ try {
110
+ rmSync(dir, { recursive: true, force: true });
111
+ } catch {
112
+ // Best-effort cleanup.
113
+ }
114
+ }
115
+ };
116
+
117
+ // `escapeRegExp` is exported so tests / callers can build patterns over the
118
+ // Redacted output without re-implementing escaping.
119
+ export type { BetterleaksFinding, ScanResult };
120
+ export { escapeRegExp, scanAndRedact };
@@ -0,0 +1,346 @@
1
+ import { type Segment, hasBypass } from '../lib/bash';
2
+ import { type Decision, allow, ask, deny } 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
+ seg.flags.some((f) => flags.includes(f));
17
+
18
+ const SPECS: readonly Spec[] = [
19
+ // ── catastrophic deletions ────────────────────────────────────────────
20
+ {
21
+ rule: 'rm-rf-root',
22
+ action: 'deny',
23
+ message:
24
+ 'rm -rf / is catastrophic. If the goal is cleaning a subdirectory, scope the path inside the project (e.g. ./dist).',
25
+ match: (seg) =>
26
+ seg.head === 'rm' && flagPresent(seg, '-rf', '-fr', '-Rf', '-fR') && seg.tokens.includes('/'),
27
+ },
28
+ {
29
+ rule: 'rm-rf-home',
30
+ action: 'deny',
31
+ message: 'rm -rf on $HOME / ~ would erase the home directory. Refuse.',
32
+ match: (seg) =>
33
+ seg.head === 'rm' &&
34
+ flagPresent(seg, '-rf', '-fr', '-Rf', '-fR') &&
35
+ seg.tokens.some((t) => /^(~|\$HOME|\$\{HOME\})$/.test(t)),
36
+ },
37
+ {
38
+ rule: 'fork-bomb',
39
+ action: 'deny',
40
+ message: 'Fork bomb pattern detected. Refuse.',
41
+ match: (_seg, raw) => /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;/.test(raw),
42
+ },
43
+ {
44
+ rule: 'dd-raw-device',
45
+ action: 'deny',
46
+ message: 'dd writing to a raw block device wipes the disk. Refuse.',
47
+ match: (seg) => seg.head === 'dd' && /\bof=\/dev\/(disk|sd|nvme|rdisk)/i.test(argsJoined(seg)),
48
+ },
49
+ {
50
+ rule: 'mkfs',
51
+ action: 'deny',
52
+ message: 'mkfs formats a filesystem. Refuse.',
53
+ match: (seg) => /^mkfs(\.[a-z0-9]+)?$/i.test(seg.head),
54
+ },
55
+ {
56
+ rule: 'kill-all',
57
+ action: 'deny',
58
+ message: 'kill -9 -1 kills every process you own. Refuse.',
59
+ match: (seg) => seg.head === 'kill' && seg.tokens.includes('-9') && seg.tokens.includes('-1'),
60
+ },
61
+ {
62
+ rule: 'chmod-777-recursive',
63
+ action: 'deny',
64
+ message:
65
+ 'Recursive chmod 777 makes everything world-writable. Use 755 for directories, 644 for files, scoped narrowly.',
66
+ match: (seg) =>
67
+ seg.head === 'chmod' && seg.flags.some((f) => f.includes('R')) && seg.tokens.includes('777'),
68
+ },
69
+
70
+ // ── git: detailed policy lives in bash-git.ts (smarter, supports
71
+ // ── `git -C <dir>`, conventional-commit enforcement, etc.) ──────────
72
+
73
+ // ── verify-skipping & signing-bypass ──────────────────────────────────
74
+ {
75
+ rule: 'no-verify',
76
+ action: 'deny',
77
+ message: '--no-verify skips git hooks. Per policy: never skip hooks. Fix the underlying issue.',
78
+ match: (seg) => seg.tokens.includes('--no-verify'),
79
+ },
80
+ {
81
+ rule: 'no-gpg-sign',
82
+ action: 'deny',
83
+ message: 'Bypassing GPG signing is off-limits unless explicitly requested.',
84
+ match: (_seg, raw) => /--no-gpg-sign\b|-c\s+commit\.gpgsign=false/.test(raw),
85
+ },
86
+
87
+ // ── sudo: ask ─────────────────────────────────────────────────────────
88
+ {
89
+ rule: 'sudo',
90
+ action: 'ask',
91
+ message:
92
+ 'sudo escalates privileges and is almost never needed in a coding session. If genuinely required, explain why; otherwise find a non-sudo path.',
93
+ match: (seg) => seg.head === 'sudo',
94
+ },
95
+
96
+ // ── macOS / system destructive ────────────────────────────────────────
97
+ {
98
+ rule: 'shutdown',
99
+ action: 'deny',
100
+ message:
101
+ 'shutdown / reboot / halt control the machine. Refuse — system control should be done manually.',
102
+ match: (seg) => ['shutdown', 'reboot', 'halt', 'poweroff'].includes(seg.head),
103
+ },
104
+ {
105
+ rule: 'launchctl-mutation',
106
+ action: 'deny',
107
+ message:
108
+ 'launchctl load/unload/bootstrap/bootout/kickstart mutates system services. Refuse — surface the intent instead.',
109
+ match: (seg) =>
110
+ seg.head === 'launchctl' &&
111
+ typeof seg.tokens[1] === 'string' &&
112
+ ['load', 'unload', 'bootstrap', 'bootout', 'kickstart', 'enable', 'disable'].includes(
113
+ seg.tokens[1],
114
+ ),
115
+ },
116
+ {
117
+ rule: 'defaults-write',
118
+ action: 'deny',
119
+ message: '`defaults write` mutates macOS preferences. Refuse — surface the intent.',
120
+ match: (seg) => seg.head === 'defaults' && seg.tokens[1] === 'write',
121
+ },
122
+ {
123
+ rule: 'csrutil',
124
+ action: 'deny',
125
+ message: 'csrutil controls System Integrity Protection. Refuse.',
126
+ match: (seg) => seg.head === 'csrutil',
127
+ },
128
+ {
129
+ rule: 'nvram',
130
+ action: 'deny',
131
+ message: 'nvram modifies firmware variables. Refuse.',
132
+ match: (seg) => seg.head === 'nvram',
133
+ },
134
+ {
135
+ rule: 'diskutil-destructive',
136
+ action: 'deny',
137
+ message: 'diskutil eraseDisk / reformat / partitionDisk wipes disks. Refuse.',
138
+ match: (seg) =>
139
+ seg.head === 'diskutil' &&
140
+ typeof seg.tokens[1] === 'string' &&
141
+ ['eraseDisk', 'reformat', 'partitionDisk', 'eraseVolume', 'secureErase'].includes(
142
+ seg.tokens[1],
143
+ ),
144
+ },
145
+ {
146
+ rule: 'tmutil-destructive',
147
+ action: 'deny',
148
+ message: 'tmutil delete / disablelocal touches Time Machine. Refuse.',
149
+ match: (seg) =>
150
+ seg.head === 'tmutil' &&
151
+ typeof seg.tokens[1] === 'string' &&
152
+ ['delete', 'disablelocal'].includes(seg.tokens[1]),
153
+ },
154
+ {
155
+ rule: 'osascript',
156
+ action: 'ask',
157
+ message:
158
+ 'osascript runs arbitrary AppleScript and can do almost anything (move files, send emails, drive apps). Confirm with Sean what you want done before running it.',
159
+ match: (seg) => seg.head === 'osascript',
160
+ },
161
+ {
162
+ rule: 'topgrade',
163
+ action: 'deny',
164
+ message:
165
+ 'topgrade upgrades everything (brew, mas, npm globals, rust, mise). System upgrades should be done manually.',
166
+ match: (seg) => seg.head === 'topgrade',
167
+ },
168
+ {
169
+ rule: 'rsync-delete',
170
+ action: 'deny',
171
+ message:
172
+ 'rsync --delete removes files at the destination. High blast radius — surface the intent instead.',
173
+ match: (seg) => seg.head === 'rsync' && seg.flags.includes('--delete'),
174
+ },
175
+ {
176
+ rule: 'softwareupdate-install',
177
+ action: 'deny',
178
+ message:
179
+ '`softwareupdate --install / -i / -d` triggers macOS system updates. Refuse — system updates should be done manually.',
180
+ match: (seg) =>
181
+ seg.head === 'softwareupdate' &&
182
+ seg.flags.some(
183
+ (f) => f === '--install' || f === '-i' || f === '-d' || f.startsWith('--download'),
184
+ ),
185
+ },
186
+ {
187
+ rule: 'pmset-write',
188
+ action: 'deny',
189
+ message:
190
+ '`pmset` (with arguments) writes power-management settings. Read-only `pmset -g` is fine; mutations need Sean.',
191
+ match: (seg) =>
192
+ seg.head === 'pmset' &&
193
+ seg.tokens.length > 1 &&
194
+ !seg.tokens.includes('-g') &&
195
+ !seg.tokens.includes('-G'),
196
+ },
197
+ {
198
+ rule: 'dscl-mutate',
199
+ action: 'deny',
200
+ message:
201
+ '`dscl . -create / -delete / -append / -change / -merge` modifies the local directory service (your user account, groups, etc.). Refuse.',
202
+ match: (seg) =>
203
+ seg.head === 'dscl' &&
204
+ seg.tokens.some((t) =>
205
+ ['-create', '-delete', '-append', '-change', '-merge', '-passwd'].includes(t),
206
+ ),
207
+ },
208
+ {
209
+ rule: 'xattr-quarantine-bypass',
210
+ action: 'deny',
211
+ message:
212
+ "`xattr -d com.apple.quarantine` removes Gatekeeper's quarantine bit — the macOS protection against running untrusted binaries. Refuse.",
213
+ match: (seg) =>
214
+ seg.head === 'xattr' &&
215
+ seg.tokens.includes('-d') &&
216
+ seg.tokens.some((t) => t.includes('com.apple.quarantine')),
217
+ },
218
+ {
219
+ rule: 'spctl-disable',
220
+ action: 'deny',
221
+ message:
222
+ '`spctl --master-disable` / `--global-disable` disables Gatekeeper system-wide. Refuse.',
223
+ match: (seg) =>
224
+ seg.head === 'spctl' &&
225
+ seg.flags.some(
226
+ (f) => f === '--master-disable' || f === '--global-disable' || f === '--disable',
227
+ ),
228
+ },
229
+ {
230
+ rule: 'kextload',
231
+ action: 'deny',
232
+ message:
233
+ 'Loading a kernel extension (`kextload`, `kmutil load`) is a system-level mutation. Refuse — Sean handles this manually.',
234
+ match: (seg) =>
235
+ seg.head === 'kextload' ||
236
+ seg.head === 'kextunload' ||
237
+ (seg.head === 'kmutil' && (seg.tokens[1] === 'load' || seg.tokens[1] === 'unload')),
238
+ },
239
+ {
240
+ rule: 'security-keychain-destructive',
241
+ action: 'deny',
242
+ message:
243
+ '`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.',
244
+ match: (seg) =>
245
+ seg.head === 'security' &&
246
+ typeof seg.tokens[1] === 'string' &&
247
+ [
248
+ 'delete-keychain',
249
+ 'delete-generic-password',
250
+ 'delete-internet-password',
251
+ 'delete-certificate',
252
+ 'delete-identity',
253
+ 'set-keychain-settings',
254
+ 'unlock-keychain',
255
+ 'lock-keychain',
256
+ 'create-keychain',
257
+ ].includes(seg.tokens[1]),
258
+ },
259
+ {
260
+ rule: 'security-keychain-add-write',
261
+ action: 'ask',
262
+ message:
263
+ '`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.',
264
+ match: (seg) =>
265
+ seg.head === 'security' &&
266
+ typeof seg.tokens[1] === 'string' &&
267
+ ['add-generic-password', 'add-internet-password', 'add-certificate'].includes(seg.tokens[1]),
268
+ },
269
+ {
270
+ rule: 'systemsetup',
271
+ action: 'deny',
272
+ message:
273
+ '`systemsetup -set...` writes machine-wide settings (timezone, sleep, network time, restart-on-power-failure). Refuse.',
274
+ match: (seg) => seg.head === 'systemsetup' && seg.tokens.some((t) => t.startsWith('-set')),
275
+ },
276
+ {
277
+ rule: 'scutil-set',
278
+ action: 'deny',
279
+ message:
280
+ '`scutil --set` writes system configuration (computer name, hostname, LocalHostName). Refuse — read-only `scutil --get` is fine.',
281
+ match: (seg) => seg.head === 'scutil' && seg.tokens.includes('--set'),
282
+ },
283
+
284
+ // ── package mutation: ask before installing/removing ──────────────────
285
+ {
286
+ rule: 'brew-mutation',
287
+ action: 'ask',
288
+ message:
289
+ '`brew install/uninstall/upgrade/untap` modifies the machine globally. Confirm before running.',
290
+ match: (seg) =>
291
+ seg.head === 'brew' &&
292
+ typeof seg.tokens[1] === 'string' &&
293
+ ['install', 'uninstall', 'upgrade', 'reinstall', 'untap', 'tap'].includes(seg.tokens[1]),
294
+ },
295
+ {
296
+ rule: 'mas-mutation',
297
+ action: 'ask',
298
+ message: '`mas install/uninstall` changes installed Mac App Store apps. Confirm.',
299
+ match: (seg) =>
300
+ seg.head === 'mas' &&
301
+ typeof seg.tokens[1] === 'string' &&
302
+ ['install', 'uninstall', 'purchase'].includes(seg.tokens[1]),
303
+ },
304
+
305
+ // ── cloud destructive ────────────────────────────────────────────────
306
+ {
307
+ rule: 'gh-destructive',
308
+ action: 'deny',
309
+ message: 'gh repo/release/issue delete is destructive on shared state. Refuse.',
310
+ match: (seg) =>
311
+ seg.head === 'gh' &&
312
+ typeof seg.tokens[1] === 'string' &&
313
+ typeof seg.tokens[2] === 'string' &&
314
+ ['repo', 'release', 'issue', 'pr'].includes(seg.tokens[1]) &&
315
+ ['delete', 'destroy', 'remove'].includes(seg.tokens[2]),
316
+ },
317
+ {
318
+ rule: 'flyctl-destroy',
319
+ action: 'deny',
320
+ message: 'flyctl apps destroy / volumes destroy nukes deployed infra. Refuse.',
321
+ match: (seg) =>
322
+ seg.head === 'flyctl' && seg.tokens.some((t) => t === 'destroy' || t === 'delete'),
323
+ },
324
+ {
325
+ rule: 'gcloud-delete',
326
+ action: 'deny',
327
+ message: '`gcloud ... delete` affects cloud resources. Refuse.',
328
+ match: (seg) => seg.head === 'gcloud' && seg.tokens.includes('delete'),
329
+ },
330
+ ];
331
+
332
+ const bashDeny = (segments: readonly Segment[], cmd: string): Decision => {
333
+ if (hasBypass(cmd)) {
334
+ return allow('bash-deny');
335
+ }
336
+ for (const seg of segments) {
337
+ for (const s of SPECS) {
338
+ if (s.match(seg, cmd)) {
339
+ return s.action === 'deny' ? deny(s.rule, s.message) : ask(s.rule, s.message);
340
+ }
341
+ }
342
+ }
343
+ return allow('bash-deny');
344
+ };
345
+
346
+ export { bashDeny };