@seanmozeik/tripwire 0.6.7 → 0.7.1

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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -141
  3. package/dist/index.js +35 -0
  4. package/dist/tripwire-cli.js +2 -10
  5. package/dist/tripwire-hook.js +3 -0
  6. package/dist/tripwire-pi.js +4 -0
  7. package/dist/tripwire.js +135 -90
  8. package/dist/types/dispatch.d.ts +18 -0
  9. package/dist/types/index.d.ts +6 -0
  10. package/dist/types/lib/bash.d.ts +27 -0
  11. package/dist/types/lib/config.d.ts +110 -0
  12. package/dist/types/lib/cursor.d.ts +16 -0
  13. package/dist/types/lib/decision.d.ts +13 -0
  14. package/dist/types/lib/diff.d.ts +3 -0
  15. package/dist/types/lib/event.d.ts +45 -0
  16. package/dist/types/lib/log.d.ts +2 -0
  17. package/dist/types/lib/secrets.d.ts +41 -0
  18. package/dist/types/rules/bash-deny.d.ts +5 -0
  19. package/dist/types/rules/bash-git.d.ts +5 -0
  20. package/dist/types/rules/bash-network-install.d.ts +4 -0
  21. package/dist/types/rules/bash-redirect.d.ts +4 -0
  22. package/dist/types/rules/bash-scoped-rm.d.ts +5 -0
  23. package/dist/types/rules/bash-tar-explosion.d.ts +4 -0
  24. package/dist/types/rules/config-custom.d.ts +6 -0
  25. package/dist/types/rules/lazy-code.d.ts +4 -0
  26. package/dist/types/rules/path-protect.d.ts +12 -0
  27. package/dist/types/rules/post-secret-scrub.d.ts +12 -0
  28. package/dist/types/rules/read-protect.d.ts +4 -0
  29. package/dist/types/rules/tool-policy.d.ts +5 -0
  30. package/package.json +53 -22
  31. package/dist/tripwire-cli.js.jsc +0 -0
  32. package/dist/tripwire.js.jsc +0 -0
  33. package/src/cli.ts +0 -264
  34. package/src/dispatch.ts +0 -354
  35. package/src/index.ts +0 -6
  36. package/src/lib/bash.ts +0 -1284
  37. package/src/lib/config.ts +0 -127
  38. package/src/lib/decision.ts +0 -36
  39. package/src/lib/diff.ts +0 -26
  40. package/src/lib/event.ts +0 -106
  41. package/src/lib/install.ts +0 -238
  42. package/src/lib/log.ts +0 -24
  43. package/src/lib/secrets.ts +0 -121
  44. package/src/rules/bash-deny.ts +0 -394
  45. package/src/rules/bash-git.ts +0 -603
  46. package/src/rules/bash-network-install.ts +0 -72
  47. package/src/rules/bash-redirect.ts +0 -91
  48. package/src/rules/bash-scoped-rm.ts +0 -84
  49. package/src/rules/bash-tar-explosion.ts +0 -76
  50. package/src/rules/bash-tool-policy.ts +0 -146
  51. package/src/rules/config-custom.ts +0 -160
  52. package/src/rules/lazy-code.ts +0 -95
  53. package/src/rules/path-protect.ts +0 -68
  54. package/src/rules/post-secret-scrub.ts +0 -38
  55. package/src/rules/read-protect.ts +0 -67
@@ -1,91 +0,0 @@
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: /(?<prefix>^|\/)\.env(?<ext>\.[^/]+)?$/,
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: /(?<prefix>^|\/)\.dev\.vars(?<ext>\.[^/]+)?$/,
18
- message: 'Refusing to write into .dev.vars (Cloudflare/Wrangler secrets).',
19
- },
20
- {
21
- rule: 'redirect-ssh',
22
- pattern: /(?<prefix>^|\/)\.ssh\//,
23
- message: 'Refusing to write into ~/.ssh/ via shell.',
24
- },
25
- {
26
- rule: 'redirect-key',
27
- pattern: /\.(?<ext>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: /(?<prefix>^|\/)\.aws\/credentials$/,
33
- message: 'Refusing to write into ~/.aws/credentials via shell.',
34
- },
35
- {
36
- rule: 'redirect-netrc',
37
- pattern: /(?<prefix>^|\/)\.netrc$/,
38
- message: 'Refusing to write into ~/.netrc via shell.',
39
- },
40
- {
41
- rule: 'redirect-block-device',
42
- pattern: /^\/dev\/(?<type>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 };
@@ -1,84 +0,0 @@
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 /tmp/graveyard-$USER, recoverable until reboot) 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 };
@@ -1,76 +0,0 @@
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|\$\{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 };
@@ -1,146 +0,0 @@
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 the user has explicitly replaced (npm/pip/patch-package); soft-warn
6
- // Suggesting modern equivalents (find→fd, grep→rg).
7
- //
8
- // Hard-deny rationale (from the user'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 different 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`. The user is bun-only across their 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`. The user 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`. The user 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 the user 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`. The user 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; recurses by default; respects .gitignore by default (use `-u`/`-uu` to include ignored/hidden files); searches the CWD when no path is given. NOT a clean flag-for-flag swap; some flags differ or are unneeded. Carry over fine: `-i`, `-n`, `-v`, `-l`, `-c`. WATCH OUT: `-r` is NOT recursive in rg, it means `--replace` (rg already recurses), so `rg -rn "PATTERN" dir/` silently parses as `--replace=n` and rewrites every match to the literal "n" while exiting 0. Drop the `-r`: `grep -rn PATTERN .` → `rg -n PATTERN`. And `grep --include`/`--exclude` become `rg -g GLOB`/`-g !GLOB`.',
92
- fires: (seg) => seg.head === 'grep' || seg.head === 'egrep' || seg.head === 'fgrep',
93
- },
94
- {
95
- rule: 'rg-r-is-replace',
96
- action: 'warn',
97
- message:
98
- 'STOP — your rg output is probably mangled. `-r` in rg means `--replace`, NOT recursive, so any `-r{X}` flag combo silently rewrites every match to the literal string "{X}" and exits 0 with no error. ripgrep is always recursive by default — there is no `-r` for recursion. If you want to search: drop the `-r` entirely (`rg -rn PATTERN` → `rg -n PATTERN`). If you genuinely want text substitution: use `--replace` explicitly.',
99
- fires: (seg) => {
100
- if (seg.head !== 'rg') {
101
- return false;
102
- }
103
- return seg.flags.some((f) => f.startsWith('-') && !f.startsWith('--') && f.includes('r'));
104
- },
105
- },
106
- {
107
- rule: 'consider-btop',
108
- action: 'warn',
109
- message: 'Consider `btop` instead of `top`. Better UI, more info, modern.',
110
- fires: (seg) => seg.head === 'top',
111
- },
112
- {
113
- rule: 'consider-dust',
114
- action: 'warn',
115
- message: 'Consider `dust` instead of `du -sh`. Sorted, colorful, faster.',
116
- fires: (seg) => seg.head === 'du',
117
- },
118
- {
119
- rule: 'consider-duf',
120
- action: 'warn',
121
- message: 'Consider `duf` instead of `df -h`. Better formatting, more readable.',
122
- fires: (seg) => seg.head === 'df',
123
- },
124
- {
125
- rule: 'consider-procs',
126
- action: 'warn',
127
- message: 'Consider `procs` instead of `ps aux`. Better filtering and output.',
128
- fires: (seg) => seg.head === 'ps',
129
- },
130
- ];
131
-
132
- const bashToolPolicy = (segments: readonly Segment[], cmd: string): Decision => {
133
- if (hasBypass(cmd)) {
134
- return allow('bash-tool-policy');
135
- }
136
- for (const seg of segments) {
137
- for (const p of POLICIES) {
138
- if (p.fires(seg)) {
139
- return p.action === 'deny' ? deny(p.rule, p.message) : warn(p.rule, p.message);
140
- }
141
- }
142
- }
143
- return allow('bash-tool-policy');
144
- };
145
-
146
- export { bashToolPolicy };
@@ -1,160 +0,0 @@
1
- // Config-based custom blocking/allowing rules.
2
- // Uses shell parsing utilities to match command patterns from config.
3
-
4
- import { hasBypass, 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
- const BYPASS_HELP = 'If this is intentional, append ` # tripwire-allow: <reason>` to the command.';
9
-
10
- const ALIASES: ReadonlyMap<string, string> = new Map([
11
- ['add', 'create'],
12
- ['new', 'create'],
13
- ['edit', 'update'],
14
- ['set', 'update'],
15
- ['rm', 'delete'],
16
- ['del', 'delete'],
17
- ['remove', 'delete'],
18
- ]);
19
-
20
- const canonical = (token: string): string => ALIASES.get(token) ?? token;
21
-
22
- // Strip directory prefix so an absolute or homebrew-style path matches its
23
- // Basename — `/opt/homebrew/bin/gog` and `gog` are the same command for
24
- // Policy purposes. shim's typed dispatcher resolves CLIs to absolute paths,
25
- // So matchers that compare `seg.head` literally would otherwise miss every
26
- // Rule for those invocations.
27
- const basename = (token: string): string => {
28
- const idx = token.lastIndexOf('/');
29
- return idx === -1 ? token : token.slice(idx + 1);
30
- };
31
-
32
- const flagPresent = (tokens: readonly string[], flag: string): boolean =>
33
- tokens.some((t) => t === flag || t.startsWith(`${flag}=`));
34
-
35
- const flagValue = (tokens: readonly string[], flag: string): string | null => {
36
- for (let i = 0; i < tokens.length; i++) {
37
- const t = tokens[i]!;
38
- if (t === flag) {
39
- return tokens[i + 1] ?? '';
40
- }
41
- if (t.startsWith(`${flag}=`)) {
42
- return t.slice(flag.length + 1);
43
- }
44
- }
45
- return null;
46
- };
47
-
48
- const subcommandTokens = (seg: Segment): string[] => {
49
- const out: string[] = [];
50
- const tokens = seg.tokens.slice(1);
51
- for (let i = 0; i < tokens.length; i++) {
52
- const t = tokens[i]!;
53
- if (t.startsWith('-')) {
54
- // Without per-CLI flag metadata, we conservatively treat
55
- // `--flag value` / `-f value` as one option pair and `--flag=value`
56
- // As one token. This keeps global selectors like `--account X`
57
- // Out of the subcommand path, at the cost of not distinguishing
58
- // Boolean flags that precede positional args.
59
- if (!t.includes('=') && tokens[i + 1] !== undefined && !tokens[i + 1]!.startsWith('-')) {
60
- i++;
61
- }
62
- continue;
63
- }
64
- out.push(t);
65
- }
66
- return out;
67
- };
68
-
69
- // Match a pattern against parsed segments using shell parsing.
70
- // This is more powerful than simple regex because it uses the same
71
- // Parsing logic as the rest of tripwire.
72
- const matchPattern = (segments: readonly Segment[], rule: BlockRule): boolean => {
73
- const pattern = rule.pattern;
74
- const patternSegs = parseCommand(pattern);
75
- if (patternSegs.length === 0) {
76
- return false;
77
- }
78
-
79
- const patternTokens = patternSegs[0]!.tokens;
80
- const patternHead = patternTokens[0];
81
- if (patternHead === undefined) {
82
- return false;
83
- }
84
- const patternSubcommands = patternTokens.slice(1);
85
-
86
- for (const seg of segments) {
87
- if (basename(seg.head) !== basename(patternHead)) {
88
- continue;
89
- }
90
-
91
- if (patternSubcommands.length > 0) {
92
- const actualSubcommands = subcommandTokens(seg);
93
- const pathMatches = patternSubcommands.every(
94
- (p, i) =>
95
- actualSubcommands[i] !== undefined && canonical(actualSubcommands[i]) === canonical(p),
96
- );
97
- if (!pathMatches) {
98
- continue;
99
- }
100
- }
101
-
102
- if ((rule.requiresFlags ?? []).some((flag) => !flagPresent(seg.tokens, flag))) {
103
- continue;
104
- }
105
-
106
- const valueChecks = rule.forbidsFlagValues ?? [];
107
- const valuesMatch = valueChecks.every((check) => {
108
- const value = flagValue(seg.tokens, check.flag);
109
- return value !== null && check.values.includes(value);
110
- });
111
- if (!valuesMatch) {
112
- continue;
113
- }
114
-
115
- if (
116
- patternSubcommands.length === 0 &&
117
- rule.requiresFlags === undefined &&
118
- rule.forbidsFlagValues === undefined
119
- ) {
120
- return true;
121
- }
122
-
123
- return true;
124
- }
125
- return false;
126
- };
127
-
128
- export const configCustom = (
129
- segments: readonly Segment[],
130
- cmd: string,
131
- blockedCommands: readonly BlockRule[],
132
- allowedCommands: readonly BlockRule[],
133
- ): Decision => {
134
- if (hasBypass(cmd)) {
135
- return allow('config-custom');
136
- }
137
-
138
- // Check allowed first (overrides blocks)
139
- for (const allowRule of allowedCommands) {
140
- if (matchPattern(segments, allowRule)) {
141
- return allow('config-custom');
142
- }
143
- }
144
-
145
- // Then check blocked
146
- for (const blockRule of blockedCommands) {
147
- if (matchPattern(segments, blockRule)) {
148
- const message = blockRule.message.includes('tripwire-allow')
149
- ? blockRule.message
150
- : `${blockRule.message} ${BYPASS_HELP}`;
151
- return blockRule.action === 'ask'
152
- ? ask('config-custom', message)
153
- : deny('config-custom', message);
154
- }
155
- }
156
-
157
- return allow('config-custom');
158
- };
159
-
160
- export { matchPattern };
@@ -1,95 +0,0 @@
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>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 (?<state>implemented|done)\b/i,
30
- /\bstubbed\b/i,
31
- ];
32
-
33
- const CODE_EXT_RE =
34
- /\.(?<ext>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
- /(?<prefix>^|\/)(?<dir>__tests__|tests?|spec|fixtures?|mocks?|__mocks__|stories)(?<suffix>\/|$)|\.(?<ext>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 };