@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,91 @@
1
+ import { type Segment, hasBypass } from '../lib/bash';
2
+ import { type Decision, allow, deny } from '../lib/decision';
3
+
4
+ // Block writes (via shell redirect, tee, cp, mv) that target sensitive
5
+ // Files. Catches the exfil-via-redirect gap that path-protect can't see
6
+ // Because it only watches Edit/Write tool calls.
7
+
8
+ const PROTECTED_TARGET_RE: readonly { rule: string; pattern: RegExp; message: string }[] = [
9
+ {
10
+ rule: 'redirect-env',
11
+ pattern: /(^|\/)\.env(\.[^/]+)?$/,
12
+ message:
13
+ 'Refusing to write into a .env file via shell redirect / tee / cp / mv. .env files hold secrets — never overwrite from a tool call.',
14
+ },
15
+ {
16
+ rule: 'redirect-dev-vars',
17
+ pattern: /(^|\/)\.dev\.vars(\.[^/]+)?$/,
18
+ message: 'Refusing to write into .dev.vars (Cloudflare/Wrangler secrets).',
19
+ },
20
+ {
21
+ rule: 'redirect-ssh',
22
+ pattern: /(^|\/)\.ssh\//,
23
+ message: 'Refusing to write into ~/.ssh/ via shell.',
24
+ },
25
+ {
26
+ rule: 'redirect-key',
27
+ pattern: /\.(pem|key|p12|pfx)$/i,
28
+ message: 'Refusing to overwrite a private-key-shaped file via shell.',
29
+ },
30
+ {
31
+ rule: 'redirect-aws-credentials',
32
+ pattern: /(^|\/)\.aws\/credentials$/,
33
+ message: 'Refusing to write into ~/.aws/credentials via shell.',
34
+ },
35
+ {
36
+ rule: 'redirect-netrc',
37
+ pattern: /(^|\/)\.netrc$/,
38
+ message: 'Refusing to write into ~/.netrc via shell.',
39
+ },
40
+ {
41
+ rule: 'redirect-block-device',
42
+ pattern: /^\/dev\/(sd|disk|nvme|rdisk)/i,
43
+ message: 'Redirecting into a raw block device wipes the disk. Refuse.',
44
+ },
45
+ ];
46
+
47
+ const checkPath = (path: string): Decision | null => {
48
+ for (const p of PROTECTED_TARGET_RE) {
49
+ if (p.pattern.test(path)) {
50
+ return deny(p.rule, p.message);
51
+ }
52
+ }
53
+ return null;
54
+ };
55
+
56
+ const bashRedirect = (segments: readonly Segment[], cmd: string): Decision => {
57
+ if (hasBypass(cmd)) {
58
+ return allow('bash-redirect');
59
+ }
60
+ for (const seg of segments) {
61
+ for (const r of seg.redirects) {
62
+ if (r.op === '>' || r.op === '>>') {
63
+ const d = checkPath(r.target);
64
+ if (d !== null) {
65
+ return d;
66
+ }
67
+ }
68
+ }
69
+ if (seg.head === 'tee') {
70
+ for (const t of seg.args) {
71
+ const d = checkPath(t);
72
+ if (d !== null) {
73
+ return d;
74
+ }
75
+ }
76
+ }
77
+ if (seg.head === 'cp' || seg.head === 'mv') {
78
+ // The destination is the last positional arg.
79
+ const dst = seg.args.at(-1);
80
+ if (dst !== undefined) {
81
+ const d = checkPath(dst);
82
+ if (d !== null) {
83
+ return d;
84
+ }
85
+ }
86
+ }
87
+ }
88
+ return allow('bash-redirect');
89
+ };
90
+
91
+ export { bashRedirect };
@@ -0,0 +1,84 @@
1
+ import { type Segment, hasBypass, isSafePathTarget, safeScopesSummary } from '../lib/bash';
2
+ import type { SafePathsConfig } from '../lib/config';
3
+ import { type Decision, allow, deny } from '../lib/decision';
4
+
5
+ interface Issue {
6
+ readonly kind: 'rm' | 'find -delete';
7
+ readonly targets: readonly string[];
8
+ }
9
+
10
+ const analyzeRm = (seg: Segment, config: SafePathsConfig): readonly string[] => {
11
+ // `rm -- foo` ends flag parsing. Treat -- as flag-like and stop after it.
12
+ let endOfFlags = false;
13
+ const targets: string[] = [];
14
+ for (const t of seg.tokens.slice(1)) {
15
+ if (!endOfFlags && t === '--') {
16
+ endOfFlags = true;
17
+ continue;
18
+ }
19
+ if (!endOfFlags && t.startsWith('-') && t !== '-') {
20
+ continue;
21
+ }
22
+ targets.push(t);
23
+ }
24
+ const extraRelative = config.relative ?? [];
25
+ const extraAbsolute = config.absolute ?? [];
26
+ return targets.filter((t) => !isSafePathTarget(t, extraRelative, extraAbsolute));
27
+ };
28
+
29
+ const analyzeFindDelete = (seg: Segment, config: SafePathsConfig): readonly string[] | null => {
30
+ if (!seg.tokens.includes('-delete')) {
31
+ return null;
32
+ }
33
+ const paths: string[] = [];
34
+ for (const t of seg.tokens.slice(1)) {
35
+ if (t.startsWith('-')) {
36
+ break;
37
+ }
38
+ paths.push(t);
39
+ }
40
+ const checked = paths.length === 0 ? ['.'] : paths;
41
+ const extraRelative = config.relative ?? [];
42
+ const extraAbsolute = config.absolute ?? [];
43
+ return checked.filter((p) => !isSafePathTarget(p, extraRelative, extraAbsolute));
44
+ };
45
+
46
+ const bashScopedRm = (
47
+ segments: readonly Segment[],
48
+ cmd: string,
49
+ config: SafePathsConfig,
50
+ ): Decision => {
51
+ if (hasBypass(cmd)) {
52
+ return allow('bash-scoped-rm');
53
+ }
54
+ const issues: Issue[] = [];
55
+ for (const seg of segments) {
56
+ if (seg.head === 'rm') {
57
+ const unsafe = analyzeRm(seg, config);
58
+ if (unsafe.length > 0) {
59
+ issues.push({ kind: 'rm', targets: unsafe });
60
+ }
61
+ continue;
62
+ }
63
+ if (seg.head === 'find') {
64
+ const unsafe = analyzeFindDelete(seg, config);
65
+ if (unsafe !== null && unsafe.length > 0) {
66
+ issues.push({ kind: 'find -delete', targets: unsafe });
67
+ }
68
+ }
69
+ }
70
+ if (issues.length === 0) {
71
+ return allow('bash-scoped-rm');
72
+ }
73
+ const extraRelative = config.relative ?? [];
74
+ const extraAbsolute = config.absolute ?? [];
75
+ const detail = issues
76
+ .map((i) => ` • ${i.kind} on: ${i.targets.map((t) => JSON.stringify(t)).join(', ')}`)
77
+ .join('\n');
78
+ return deny(
79
+ 'destructive-outside-safe-paths',
80
+ `Destructive deletion outside known-safe scopes is blocked. Use \`trash\` (macOS Trash, recoverable) or \`rip\` (graveyard at ~/.local/share/graveyard, recoverable) instead. Real \`rm\` and \`find -delete\` are allowed only inside ephemeral build / cache / state directories:\n${safeScopesSummary(extraRelative, extraAbsolute)}\n\nFlagged targets:\n${detail}\n\nIf raw \`rm\` is genuinely needed, append \` # tripwire-allow: <reason>\` to the command.`,
81
+ );
82
+ };
83
+
84
+ export { bashScopedRm };
@@ -0,0 +1,76 @@
1
+ import { type Segment, hasBypass } from '../lib/bash';
2
+ import { type Decision, allow, deny } from '../lib/decision';
3
+
4
+ // Block tar/zip/unzip extractions that would write into / or $HOME
5
+ // (`tar -xf foo.tar.gz -C /` style explosions).
6
+
7
+ const isExtractFlag = (f: string): boolean =>
8
+ f === '-x' ||
9
+ f === '-xf' ||
10
+ f === '-xzf' ||
11
+ f === '-xjf' ||
12
+ f === '-xJf' ||
13
+ f === '-xvf' ||
14
+ f === '-xvzf' ||
15
+ f === '-xvjf' ||
16
+ f === '--extract' ||
17
+ /^-[xvzjJtf]+$/.test(f);
18
+
19
+ const findChangeDir = (seg: Segment): string | null => {
20
+ for (let i = 0; i < seg.tokens.length; i++) {
21
+ const t = seg.tokens[i]!;
22
+ if (t === '-C' || t === '--directory') {
23
+ return seg.tokens[i + 1] ?? null;
24
+ }
25
+ if (t.startsWith('--directory=')) {
26
+ return t.slice('--directory='.length);
27
+ }
28
+ }
29
+ return null;
30
+ };
31
+
32
+ const isUnsafeExtractDest = (dest: string): boolean => {
33
+ return dest === '/' || /^(~|\$HOME|\$\{HOME\})$/.test(dest);
34
+ };
35
+
36
+ const bashTarExplosion = (segments: readonly Segment[], cmd: string): Decision => {
37
+ if (hasBypass(cmd)) {
38
+ return allow('bash-tar-explosion');
39
+ }
40
+ for (const seg of segments) {
41
+ if (seg.head !== 'tar') {
42
+ continue;
43
+ }
44
+ const extracting = seg.flags.some(isExtractFlag) || seg.tokens.includes('--extract');
45
+ if (!extracting) {
46
+ continue;
47
+ }
48
+ const dest = findChangeDir(seg);
49
+ if (dest !== null && isUnsafeExtractDest(dest)) {
50
+ return deny(
51
+ 'tar-extract-to-root',
52
+ `tar -x with -C ${dest} can overwrite arbitrary system files. Refuse — extract to a contained directory (e.g. ./tmp/extract) and inspect before moving anything elsewhere.`,
53
+ );
54
+ }
55
+ }
56
+ // Unzip with -d destination
57
+ for (const seg of segments) {
58
+ if (seg.head !== 'unzip') {
59
+ continue;
60
+ }
61
+ for (let i = 0; i < seg.tokens.length; i++) {
62
+ if (seg.tokens[i] === '-d') {
63
+ const dest = seg.tokens[i + 1];
64
+ if (dest !== undefined && isUnsafeExtractDest(dest)) {
65
+ return deny(
66
+ 'unzip-to-root',
67
+ `unzip -d ${dest} can overwrite arbitrary system files. Refuse — extract to a contained directory.`,
68
+ );
69
+ }
70
+ }
71
+ }
72
+ }
73
+ return allow('bash-tar-explosion');
74
+ };
75
+
76
+ export { bashTarExplosion };
@@ -0,0 +1,134 @@
1
+ import { type Segment, hasBypass } from '../lib/bash';
2
+ import { type Decision, allow, deny, warn } from '../lib/decision';
3
+
4
+ // Opinionated tooling enforcement. Hard-deny on the package managers and
5
+ // Tools Sean has explicitly replaced (npm/pip/patch-package); soft-warn
6
+ // Suggesting modern equivalents (find→fd, grep→rg).
7
+ //
8
+ // Hard-deny rationale (from Sean's CLAUDE.md): TypeScript is bun-only,
9
+ // Python is uv-only. Slipping into npm or pip mid-session means the
10
+ // Agent forgot the toolchain and is about to install into the wrong
11
+ // Directory or make a lockfile bun can't read.
12
+
13
+ interface Policy {
14
+ readonly rule: string;
15
+ readonly action: 'deny' | 'warn';
16
+ readonly message: string;
17
+ readonly fires: (seg: Segment) => boolean;
18
+ }
19
+
20
+ const POLICIES: readonly Policy[] = [
21
+ // ── HARD DENIES: wrong package manager ───────────────────────────────
22
+ {
23
+ rule: 'use-bun-not-npm',
24
+ action: 'deny',
25
+ message:
26
+ 'Use `bun` instead of `npm`. Translations: `npm install` → `bun install`, `npm install <pkg>` → `bun add <pkg>`, `npm install -D <pkg>` → `bun add -d <pkg>`, `npm run X` → `bun run X` (or `bun X` for bin scripts), `npm test` → `bun test`. If you genuinely need npm (publishing to a registry that requires it, working in a non-Sean repo), append ` # tripwire-allow: <reason>` to the command.',
27
+ fires: (seg) => seg.head === 'npm',
28
+ },
29
+ {
30
+ rule: 'use-bunx-not-npx',
31
+ action: 'deny',
32
+ message: 'Use `bunx` instead of `npx`. Same usage shape, faster, no implicit npm cache.',
33
+ fires: (seg) => seg.head === 'npx',
34
+ },
35
+ {
36
+ rule: 'use-bun-not-pnpm',
37
+ action: 'deny',
38
+ message: 'Use `bun` instead of `pnpm`. Sean is bun-only across his repos.',
39
+ fires: (seg) => seg.head === 'pnpm',
40
+ },
41
+ {
42
+ rule: 'use-bun-not-yarn',
43
+ action: 'deny',
44
+ message: 'Use `bun` instead of `yarn`. Sean is bun-only.',
45
+ fires: (seg) => seg.head === 'yarn',
46
+ },
47
+ {
48
+ rule: 'use-uv-not-pip',
49
+ action: 'deny',
50
+ message:
51
+ 'Use `uv` instead of `pip`. Translations: `pip install <pkg>` → `uv add <pkg>` (project dependency) or `uv pip install <pkg>` (env-only escape hatch). `pip freeze` → `uv pip freeze`. `pip list` → `uv pip list`. Sean is uv-only across Python repos.',
52
+ fires: (seg) => seg.head === 'pip' || seg.head === 'pip3',
53
+ },
54
+ {
55
+ rule: 'use-uv-sync-not-venv',
56
+ action: 'deny',
57
+ message:
58
+ '`python -m venv` creates a bare venv; use `uv sync` instead. uv sync creates the venv AND installs from pyproject.toml + uv.lock in one atomic step. To activate: `source .venv/bin/activate` after.',
59
+ fires: (seg) =>
60
+ (seg.head === 'python' || seg.head === 'python3') &&
61
+ seg.tokens.includes('-m') &&
62
+ seg.tokens.includes('venv'),
63
+ },
64
+ {
65
+ rule: 'uv-sync-over-uv-venv',
66
+ action: 'deny',
67
+ message:
68
+ 'Use `uv sync` instead of `uv venv`. `uv venv` creates an empty venv that you then have to populate; `uv sync` creates the venv AND resolves+installs from pyproject.toml + uv.lock in one step. The only reason to use `uv venv` standalone is when there is no pyproject.toml — and in that case, `uv init` first.',
69
+ fires: (seg) => seg.head === 'uv' && seg.tokens[1] === 'venv',
70
+ },
71
+ {
72
+ rule: 'use-bun-patch-not-patch-package',
73
+ action: 'deny',
74
+ message:
75
+ 'Use `bun patch` instead of `patch-package`. Bun has built-in patch support that integrates with bun.lock; patch-package is npm-era and produces patches in a different format.',
76
+ fires: (seg) => seg.head === 'patch-package',
77
+ },
78
+
79
+ // ── SOFT WARNS: modern equivalents Sean has installed ────────────────
80
+ {
81
+ rule: 'consider-fd',
82
+ action: 'warn',
83
+ message:
84
+ 'Consider `fd` instead of `find`. Faster, simpler syntax, respects .gitignore by default. Examples: `find . -name "*.ts"` → `fd -e ts`, `find . -type f -name "X"` → `fd -t f X`, `find PATH ...` → `fd ... PATH`. Sean has both installed; either works.',
85
+ fires: (seg) => seg.head === 'find',
86
+ },
87
+ {
88
+ rule: 'consider-rg',
89
+ action: 'warn',
90
+ message:
91
+ 'Consider `rg` (ripgrep) instead of `grep`. Faster, recursive by default, respects .gitignore, sane defaults. Most flags carry over: `-i`, `-n`, `-v`, `-l`, `-c`. `grep -r PATTERN .` → `rg PATTERN`.',
92
+ fires: (seg) => seg.head === 'grep' || seg.head === 'egrep' || seg.head === 'fgrep',
93
+ },
94
+ {
95
+ rule: 'consider-btop',
96
+ action: 'warn',
97
+ message: 'Consider `btop` instead of `top`. Better UI, more info, modern.',
98
+ fires: (seg) => seg.head === 'top',
99
+ },
100
+ {
101
+ rule: 'consider-dust',
102
+ action: 'warn',
103
+ message: 'Consider `dust` instead of `du -sh`. Sorted, colorful, faster.',
104
+ fires: (seg) => seg.head === 'du',
105
+ },
106
+ {
107
+ rule: 'consider-duf',
108
+ action: 'warn',
109
+ message: 'Consider `duf` instead of `df -h`. Better formatting, more readable.',
110
+ fires: (seg) => seg.head === 'df',
111
+ },
112
+ {
113
+ rule: 'consider-procs',
114
+ action: 'warn',
115
+ message: 'Consider `procs` instead of `ps aux`. Better filtering and output.',
116
+ fires: (seg) => seg.head === 'ps',
117
+ },
118
+ ];
119
+
120
+ const bashToolPolicy = (segments: readonly Segment[], cmd: string): Decision => {
121
+ if (hasBypass(cmd)) {
122
+ return allow('bash-tool-policy');
123
+ }
124
+ for (const seg of segments) {
125
+ for (const p of POLICIES) {
126
+ if (p.fires(seg)) {
127
+ return p.action === 'deny' ? deny(p.rule, p.message) : warn(p.rule, p.message);
128
+ }
129
+ }
130
+ }
131
+ return allow('bash-tool-policy');
132
+ };
133
+
134
+ export { bashToolPolicy };
@@ -0,0 +1,51 @@
1
+ // Config-based custom blocking/allowing rules.
2
+ // Uses shell parsing utilities to match command patterns from config.
3
+
4
+ import { parseCommand, type Segment } from '../lib/bash';
5
+ import type { BlockRule } from '../lib/config';
6
+ import { type Decision, allow, deny, ask } from '../lib/decision';
7
+
8
+ // Match a pattern against parsed segments using shell parsing.
9
+ // This is more powerful than simple regex because it uses the same
10
+ // Parsing logic as the rest of tripwire.
11
+ const matchPattern = (segments: readonly Segment[], pattern: string): boolean => {
12
+ const patternSegs = parseCommand(pattern);
13
+ if (patternSegs.length === 0) {
14
+ return false;
15
+ }
16
+
17
+ const patternHead = patternSegs[0]!.head;
18
+
19
+ // Simple head match for now - can be extended to match flags, args, etc.
20
+ for (const seg of segments) {
21
+ if (seg.head === patternHead) {
22
+ return true;
23
+ }
24
+ }
25
+ return false;
26
+ };
27
+
28
+ export const configCustom = (
29
+ segments: readonly Segment[],
30
+ _cmd: string,
31
+ blockedCommands: readonly BlockRule[],
32
+ allowedCommands: readonly BlockRule[],
33
+ ): Decision => {
34
+ // Check allowed first (overrides blocks)
35
+ for (const allowRule of allowedCommands) {
36
+ if (matchPattern(segments, allowRule.pattern)) {
37
+ return allow('config-custom');
38
+ }
39
+ }
40
+
41
+ // Then check blocked
42
+ for (const blockRule of blockedCommands) {
43
+ if (matchPattern(segments, blockRule.pattern)) {
44
+ return blockRule.action === 'deny'
45
+ ? deny('config-custom', blockRule.message)
46
+ : ask('config-custom', blockRule.message);
47
+ }
48
+ }
49
+
50
+ return allow('config-custom');
51
+ };
@@ -0,0 +1,95 @@
1
+ import { type Decision, allow, warn } from '../lib/decision';
2
+ import { addedLines, readFileOrEmpty } from '../lib/diff';
3
+ import type { EditInput, WriteInput } from '../lib/event';
4
+
5
+ // Phrases that frequently signal incomplete or deferred work. Some — like
6
+ // "fallback" or "placeholder" — are also legitimate product terms (an auth
7
+ // Fallback flow, an HTML input placeholder). Rather than try to disambiguate
8
+ // Statically, we accept the false-positive rate and keep this as a non-
9
+ // Blocking warn. The advisory is written to make the intent unmistakable
10
+ // So the agent treats real-product uses as no-action and treats actual
11
+ // Stub work as a prompt to finish the job before returning to the user.
12
+ const STUB_RE: readonly RegExp[] = [
13
+ /\bTODO\s*:/i,
14
+ /\bFIXME\s*:/i,
15
+ /\bXXX\s*:/i,
16
+ /\bHACK\s*:/i,
17
+ /\bfor now\b/i,
18
+ /\bnot implemented\b/i,
19
+ /\bNotImplementedError\b/,
20
+ /\btemp fix\b/i,
21
+ /\bfallback\b/i,
22
+ /\bplaceholder\b/i,
23
+ /\bbackwards?[ -]?compat(ibility)?\b/i,
24
+ /\bfor later\b/i,
25
+ /\blater on\b/i,
26
+ /\bget back to\b/i,
27
+ /\bI'?ll fix\b/i,
28
+ /\bto be implemented\b/i,
29
+ /\bnot yet (implemented|done)\b/i,
30
+ /\bstubbed\b/i,
31
+ ];
32
+
33
+ const CODE_EXT_RE =
34
+ /\.(ts|tsx|js|jsx|mjs|cjs|py|rs|go|rb|java|kt|swift|c|cc|cpp|h|hpp|cs|php|sh|zsh|bash|lua|ex|exs|clj|scala|dart)$/i;
35
+
36
+ const TEST_PATH_RE =
37
+ /(^|\/)(__tests__|tests?|spec|fixtures?|mocks?|__mocks__|stories)(\/|$)|\.(test|spec|fixture|mock|stories)\.[^/]+$/i;
38
+
39
+ // Comment-syntax-agnostic. Works in `//`, `#`, `--`, `/* */`, `<!-- -->`,
40
+ // `;`, `%`, etc.
41
+ const BYPASS_RE = /tripwire-allow\b/;
42
+
43
+ const matches = (line: string): boolean => {
44
+ if (BYPASS_RE.test(line)) {
45
+ return false;
46
+ }
47
+ for (const re of STUB_RE) {
48
+ if (re.test(line)) {
49
+ return true;
50
+ }
51
+ }
52
+ return false;
53
+ };
54
+
55
+ const lazyCode = (input: EditInput | WriteInput): Decision => {
56
+ const path = input.file_path;
57
+ if (!CODE_EXT_RE.test(path) || TEST_PATH_RE.test(path)) {
58
+ return allow('lazy-code');
59
+ }
60
+
61
+ const next = 'content' in input ? input.content : input.new_string;
62
+ const prev = 'content' in input ? readFileOrEmpty(path) : input.old_string;
63
+
64
+ const offenders: string[] = [];
65
+ for (const line of addedLines(prev, next)) {
66
+ if (matches(line)) {
67
+ offenders.push(line.slice(0, 200));
68
+ }
69
+ }
70
+ if (offenders.length === 0) {
71
+ return allow('lazy-code');
72
+ }
73
+
74
+ const sample = offenders
75
+ .slice(0, 3)
76
+ .map((l) => ` • ${l}`)
77
+ .join('\n');
78
+ const more = offenders.length > 3 ? `\n …and ${offenders.length - 3} more` : '';
79
+
80
+ return warn(
81
+ 'lazy-code-marker',
82
+ [
83
+ `Heads up: line(s) you just added contain words that often signal incomplete or deferred work. The write went through — this is a flag, not a block.`,
84
+ ``,
85
+ `Why this exists: AI coding agents have a strong pull toward stubbing things, deferring "for now," and shipping half-built fallbacks instead of finishing the work in the same turn. The point of this warning is to push back on that pull on every iteration. If you stubbed something out for time, finish it this turn rather than leaving deferred work for later.`,
86
+ ``,
87
+ `If the marker is genuinely permanent — a real product term ("auth fallback flow", "retry fallback chain", an HTML input placeholder, a public API field literally named "placeholder"), a logging tag, or a comment intentionally left for a human reader — no action needed. To silence the flag on subsequent edits of that line, append \`tripwire-allow: <one-line reason>\` (any comment syntax: \`//\`, \`#\`, \`--\`, etc.).`,
88
+ ``,
89
+ `Flagged additions:`,
90
+ sample + more,
91
+ ].join('\n'),
92
+ );
93
+ };
94
+
95
+ export { lazyCode };
@@ -0,0 +1,59 @@
1
+ import { resolve } from 'node:path';
2
+
3
+ import { type Decision, allow, deny } from '../lib/decision';
4
+ import type { EditInput, WriteInput } from '../lib/event';
5
+
6
+ interface Spec {
7
+ readonly pattern: RegExp;
8
+ readonly rule: string;
9
+ readonly message: string;
10
+ }
11
+
12
+ const protections: readonly Spec[] = [
13
+ {
14
+ pattern: /(^|\/)\.env(\.[^/]+)?$/,
15
+ rule: 'env-file',
16
+ message:
17
+ '.env files hold secrets that should never be sent to the model. Refuse to write or edit. If an example is needed, create .env.example with redacted placeholders.',
18
+ },
19
+ {
20
+ pattern: /(^|\/)\.dev\.vars(\.[^/]+)?$/,
21
+ rule: 'dev-vars',
22
+ message: '.dev.vars holds Cloudflare/Wrangler secrets. Do not modify.',
23
+ },
24
+ { pattern: /(^|\/)\.ssh\//, rule: 'ssh-dir', message: 'Never write into ~/.ssh/. Refuse.' },
25
+ {
26
+ pattern: /(^|\/)(id_rsa|id_ed25519|id_ecdsa|id_dsa)(\.pub)?$/,
27
+ rule: 'ssh-key',
28
+ message: 'SSH key file. Refuse.',
29
+ },
30
+ {
31
+ pattern: /\.(pem|key|p12|pfx)$/i,
32
+ rule: 'private-key',
33
+ message:
34
+ 'Private key file. Refuse to overwrite. If generating a new key, use a different filename and let Sean review.',
35
+ },
36
+ {
37
+ pattern: /(^|\/)secrets?\.(json|ya?ml|toml|env)$/i,
38
+ rule: 'secrets-file',
39
+ message: 'Secrets file. Refuse.',
40
+ },
41
+ {
42
+ pattern: /(^|\/)\.aws\/credentials$/,
43
+ rule: 'aws-credentials',
44
+ message: 'AWS credentials file. Refuse.',
45
+ },
46
+ { pattern: /(^|\/)\.netrc$/, rule: 'netrc', message: '.netrc holds host credentials. Refuse.' },
47
+ ];
48
+
49
+ const pathProtect = (input: EditInput | WriteInput): Decision => {
50
+ const path = resolve(input.file_path);
51
+ for (const p of protections) {
52
+ if (p.pattern.test(path)) {
53
+ return deny(p.rule, p.message);
54
+ }
55
+ }
56
+ return allow('path-protect');
57
+ };
58
+
59
+ export { pathProtect };
@@ -0,0 +1,38 @@
1
+ import { type Decision, allow, deny } from '../lib/decision';
2
+ import { extractResponseText } from '../lib/event';
3
+ import { scanAndRedact } from '../lib/secrets';
4
+
5
+ // PostToolUse: scan whatever string content a tool returned (Bash stdout,
6
+ // Read content) for known secret patterns via betterleaks. If anything
7
+ // Fires, block the result (so the original output never reaches the
8
+ // Model) and surface a redacted version in the block reason — that lets
9
+ // The agent see what was returned without leaking the secret itself.
10
+
11
+ interface PostInput {
12
+ readonly toolName: string;
13
+ readonly response: unknown;
14
+ }
15
+
16
+ const postSecretScrub = (input: PostInput): Decision => {
17
+ const text = extractResponseText(input.toolName, input.response);
18
+ if (text.length === 0) {
19
+ return allow('post-secret-scrub');
20
+ }
21
+ const { hits, redacted } = scanAndRedact(text);
22
+ if (hits.length === 0) {
23
+ return allow('post-secret-scrub');
24
+ }
25
+ const summary = hits.map((h) => `${h.rule}×${h.count}`).join(', ');
26
+ return deny(
27
+ 'secrets-in-output',
28
+ [
29
+ `tripwire intercepted ${hits.length} secret pattern(s) in this tool's output (${summary}). The original output was withheld so the secret never enters the model context. A redacted form is below — work from this, do not re-run the same command in a way that re-fetches the underlying secret.`,
30
+ ``,
31
+ `Redacted output:`,
32
+ redacted.slice(0, 16_000) + (redacted.length > 16_000 ? '\n…[truncated]' : ''),
33
+ ].join('\n'),
34
+ );
35
+ };
36
+
37
+ export type { PostInput };
38
+ export { postSecretScrub };