@seanmozeik/tripwire 0.6.6 → 0.7.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.
@@ -2,39 +2,13 @@ import { type Segment, collectHeredocBodies, hasBypass, unwrapStaticString } fro
2
2
  import type { GitConfig } from '../lib/config';
3
3
  import { type Decision, allow, ask, deny, warn } from '../lib/decision';
4
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 (the user'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
- ];
5
+ // Git policy separates read operations from destructive worktree, history,
6
+ // Branch, push, commit, and configuration changes. Global Git options are
7
+ // Removed before subcommand dispatch so `git -C repo reset --hard` receives
8
+ // The same decision as `git reset --hard`.
35
9
 
36
10
  const getProtectedBranches = (config: GitConfig): readonly string[] =>
37
- config.protectedBranches ?? DEFAULT_PROTECTED_BRANCHES;
11
+ config.protectedBranches ?? [];
38
12
 
39
13
  // Conventional Commits 1.0.0 — type(scope)?(!)?: description
40
14
  const CONVENTIONAL_RE =
@@ -78,13 +52,16 @@ const parseGit = (seg: Segment): GitInvocation | null => {
78
52
  const toks = seg.tokens.slice(1);
79
53
  let i = 0;
80
54
  while (i < toks.length) {
81
- const t = toks[i]!;
55
+ const t = toks[i];
56
+ if (t === undefined) {
57
+ break;
58
+ }
82
59
  if (PRE_SUB_FLAG_TAKES_VALUE.has(t)) {
83
60
  i += 2;
84
61
  continue;
85
62
  }
86
63
  if (PRE_SUB_FLAG_NO_VALUE.has(t)) {
87
- i++;
64
+ i += 1;
88
65
  continue;
89
66
  }
90
67
  if (
@@ -94,12 +71,12 @@ const parseGit = (seg: Segment): GitInvocation | null => {
94
71
  t.startsWith('--super-prefix=') ||
95
72
  t.startsWith('--exec-path=')
96
73
  ) {
97
- i++;
74
+ i += 1;
98
75
  continue;
99
76
  }
100
77
  if (t.startsWith('-')) {
101
78
  // Unknown pre-subcommand flag; assume no value, advance.
102
- i++;
79
+ i += 1;
103
80
  continue;
104
81
  }
105
82
  return { subcommand: t, subArgs: toks.slice(i + 1) };
@@ -111,8 +88,11 @@ const messageOf = (
111
88
  subArgs: readonly string[],
112
89
  heredocBodies?: ReadonlyMap<string, string>,
113
90
  ): string | null => {
114
- for (let i = 0; i < subArgs.length; i++) {
115
- const t = subArgs[i]!;
91
+ for (let i = 0; i < subArgs.length; i += 1) {
92
+ const t = subArgs[i];
93
+ if (t === undefined) {
94
+ break;
95
+ }
116
96
  if (t === '-m' || t === '--message') {
117
97
  const raw = subArgs[i + 1];
118
98
  return raw === undefined ? null : unwrapStaticString(raw, heredocBodies);
@@ -147,8 +127,10 @@ const positionalOf = (subArgs: readonly string[]): string[] =>
147
127
 
148
128
  const flagsOf = (subArgs: readonly string[]): string[] => subArgs.filter((a) => a.startsWith('-'));
149
129
 
150
- const has = (subArgs: readonly string[], ...needles: readonly string[]): boolean =>
151
- needles.some((n) => subArgs.includes(n));
130
+ const has = (subArgs: readonly string[], ...needles: readonly string[]): boolean => {
131
+ const argumentSet = new Set(subArgs);
132
+ return needles.some((needle) => argumentSet.has(needle));
133
+ };
152
134
 
153
135
  interface HandlerCtx {
154
136
  readonly subcommand: string;
@@ -218,7 +200,12 @@ const handleCheckout: Handler = ({ subArgs, positional }) => {
218
200
  '`git checkout -- <path>` discards uncommitted working-tree changes. Refuse — use `git stash push <path>` to preserve, or `git diff <path>` to inspect first.',
219
201
  );
220
202
  }
221
- if (positional.length === 1 && (positional[0] === '.' || positional[0]!.startsWith('./'))) {
203
+ const [target] = positional;
204
+ if (
205
+ target !== undefined &&
206
+ positional.length === 1 &&
207
+ (target === '.' || target.startsWith('./'))
208
+ ) {
222
209
  return deny(
223
210
  'git-checkout-discard-all',
224
211
  '`git checkout .` discards ALL uncommitted working-tree changes. Refuse — `git stash` to preserve, or `git diff` to inspect first.',
@@ -279,7 +266,7 @@ const handleRebase: Handler = ({ subArgs, positional, config }) => {
279
266
  '`git rebase -i` rewrites history interactively. Refuse — too easy to lose commits in the agent loop. If this is genuinely required, do it manually outside the agent.',
280
267
  );
281
268
  }
282
- const onto = positional[0];
269
+ const [onto] = positional;
283
270
  const branches = getProtectedBranches(config);
284
271
  if (onto !== undefined && branches.includes(onto)) {
285
272
  return ask(
@@ -326,7 +313,7 @@ const handleCommit: Handler = ({ subArgs, config, heredocBodies }) => {
326
313
  '`git commit` without `-m "..."` opens an editor and hangs the agent. Use `git commit -m "<conventional message>"`.',
327
314
  );
328
315
  }
329
- if (msg !== null && config.enforceConventionalCommits !== false && !CONVENTIONAL_RE.test(msg)) {
316
+ if (msg !== null && config.enforceConventionalCommits === true && !CONVENTIONAL_RE.test(msg)) {
330
317
  return deny(
331
318
  'git-commit-non-conventional',
332
319
  [
@@ -384,8 +371,8 @@ const handleBranch: Handler = ({ subArgs, flags, positional, config }) => {
384
371
  );
385
372
  if (deleteFlag !== undefined) {
386
373
  const targets = positional;
387
- const branches = getProtectedBranches(config);
388
- const hit = targets.find((t) => branches.includes(t));
374
+ const branches = new Set(getProtectedBranches(config));
375
+ const hit = targets.find((target) => branches.has(target));
389
376
  if (hit !== undefined) {
390
377
  return deny('git-branch-delete-protected', `Refusing to delete protected branch \`${hit}\`.`);
391
378
  }
@@ -435,7 +422,7 @@ const handleGc: Handler = ({ flags }) => {
435
422
  };
436
423
 
437
424
  const handleRemote: Handler = ({ subArgs }) => {
438
- const sub = subArgs[0];
425
+ const [sub] = subArgs;
439
426
  if (sub === 'add' || sub === 'remove' || sub === 'rm' || sub === 'set-url' || sub === 'rename') {
440
427
  return ask(
441
428
  'git-remote-mutate',
@@ -10,9 +10,12 @@ const SHELL_HEADS: ReadonlySet<string> = new Set(['bash', 'sh', 'zsh', 'fish']);
10
10
  const isFetchPipedToShell = (segments: readonly Segment[]): boolean => {
11
11
  // Shell-quote splits a pipeline `curl X | bash` into two segments. We
12
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]!;
13
+ for (let i = 0; i < segments.length - 1; i += 1) {
14
+ const a = segments[i];
15
+ const b = segments[i + 1];
16
+ if (a === undefined || b === undefined) {
17
+ continue;
18
+ }
16
19
  if (FETCH_HEADS.has(a.head) && SHELL_HEADS.has(b.head)) {
17
20
  return true;
18
21
  }
@@ -1,11 +1,12 @@
1
1
  import { type Segment, hasBypass } from '../lib/bash';
2
2
  import { type Decision, allow, deny } from '../lib/decision';
3
+ import { classifyProtectedPath, type ProtectedPathSpec } from './path-protect';
3
4
 
4
5
  // Block writes (via shell redirect, tee, cp, mv) that target sensitive
5
6
  // Files. Catches the exfil-via-redirect gap that path-protect can't see
6
7
  // Because it only watches Edit/Write tool calls.
7
8
 
8
- const PROTECTED_TARGET_RE: readonly { rule: string; pattern: RegExp; message: string }[] = [
9
+ const PROTECTED_TARGET_RE: readonly ProtectedPathSpec[] = [
9
10
  {
10
11
  rule: 'redirect-env',
11
12
  pattern: /(?<prefix>^|\/)\.env(?<ext>\.[^/]+)?$/,
@@ -45,10 +46,9 @@ const PROTECTED_TARGET_RE: readonly { rule: string; pattern: RegExp; message: st
45
46
  ];
46
47
 
47
48
  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
- }
49
+ const protection = classifyProtectedPath(path, 'write', PROTECTED_TARGET_RE);
50
+ if (protection !== null) {
51
+ return deny(protection.rule, protection.message);
52
52
  }
53
53
  return null;
54
54
  };
@@ -77,7 +77,7 @@ const bashScopedRm = (
77
77
  .join('\n');
78
78
  return deny(
79
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.`,
80
+ `Destructive deletion outside known-safe scopes is blocked. Use a recoverable deletion tool or limit the target to an ephemeral build, cache, state, or temporary directory:\n${safeScopesSummary(extraRelative, extraAbsolute)}\n\nFlagged targets:\n${detail}\n\nIf raw deletion is genuinely needed, append \` # tripwire-allow: <reason>\` to the command.`,
81
81
  );
82
82
  };
83
83
 
@@ -4,21 +4,16 @@ import { type Decision, allow, deny } from '../lib/decision';
4
4
  // Block tar/zip/unzip extractions that would write into / or $HOME
5
5
  // (`tar -xf foo.tar.gz -C /` style explosions).
6
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);
7
+ const isExtractFlag = (flag: string): boolean =>
8
+ flag === '--extract' ||
9
+ (flag.startsWith('-') && !flag.startsWith('--') && flag.slice(1).includes('x'));
18
10
 
19
11
  const findChangeDir = (seg: Segment): string | null => {
20
- for (let i = 0; i < seg.tokens.length; i++) {
21
- const t = seg.tokens[i]!;
12
+ for (let i = 0; i < seg.tokens.length; i += 1) {
13
+ const t = seg.tokens[i];
14
+ if (t === undefined) {
15
+ continue;
16
+ }
22
17
  if (t === '-C' || t === '--directory') {
23
18
  return seg.tokens[i + 1] ?? null;
24
19
  }
@@ -41,7 +36,13 @@ const bashTarExplosion = (segments: readonly Segment[], cmd: string): Decision =
41
36
  if (seg.head !== 'tar') {
42
37
  continue;
43
38
  }
44
- const extracting = seg.flags.some(isExtractFlag) || seg.tokens.includes('--extract');
39
+ const [, legacyOptionWord] = seg.tokens;
40
+ const extracting =
41
+ seg.flags.some(isExtractFlag) ||
42
+ seg.tokens.includes('--extract') ||
43
+ (legacyOptionWord !== undefined &&
44
+ /^[a-zA-Z]+$/.test(legacyOptionWord) &&
45
+ legacyOptionWord.includes('x'));
45
46
  if (!extracting) {
46
47
  continue;
47
48
  }
@@ -58,7 +59,7 @@ const bashTarExplosion = (segments: readonly Segment[], cmd: string): Decision =
58
59
  if (seg.head !== 'unzip') {
59
60
  continue;
60
61
  }
61
- for (let i = 0; i < seg.tokens.length; i++) {
62
+ for (let i = 0; i < seg.tokens.length; i += 1) {
62
63
  if (seg.tokens[i] === '-d') {
63
64
  const dest = seg.tokens[i + 1];
64
65
  if (dest !== undefined && isUnsafeExtractDest(dest)) {
@@ -19,11 +19,8 @@ const ALIASES: ReadonlyMap<string, string> = new Map([
19
19
 
20
20
  const canonical = (token: string): string => ALIASES.get(token) ?? token;
21
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.
22
+ // Match command policy by executable basename so an absolute path cannot
23
+ // bypass a configured rule.
27
24
  const basename = (token: string): string => {
28
25
  const idx = token.lastIndexOf('/');
29
26
  return idx === -1 ? token : token.slice(idx + 1);
@@ -33,8 +30,11 @@ const flagPresent = (tokens: readonly string[], flag: string): boolean =>
33
30
  tokens.some((t) => t === flag || t.startsWith(`${flag}=`));
34
31
 
35
32
  const flagValue = (tokens: readonly string[], flag: string): string | null => {
36
- for (let i = 0; i < tokens.length; i++) {
37
- const t = tokens[i]!;
33
+ for (let i = 0; i < tokens.length; i += 1) {
34
+ const t = tokens[i];
35
+ if (t === undefined) {
36
+ continue;
37
+ }
38
38
  if (t === flag) {
39
39
  return tokens[i + 1] ?? '';
40
40
  }
@@ -48,16 +48,20 @@ const flagValue = (tokens: readonly string[], flag: string): string | null => {
48
48
  const subcommandTokens = (seg: Segment): string[] => {
49
49
  const out: string[] = [];
50
50
  const tokens = seg.tokens.slice(1);
51
- for (let i = 0; i < tokens.length; i++) {
52
- const t = tokens[i]!;
51
+ for (let i = 0; i < tokens.length; i += 1) {
52
+ const t = tokens[i];
53
+ if (t === undefined) {
54
+ continue;
55
+ }
53
56
  if (t.startsWith('-')) {
54
57
  // Without per-CLI flag metadata, we conservatively treat
55
58
  // `--flag value` / `-f value` as one option pair and `--flag=value`
56
59
  // As one token. This keeps global selectors like `--account X`
57
60
  // Out of the subcommand path, at the cost of not distinguishing
58
61
  // Boolean flags that precede positional args.
59
- if (!t.includes('=') && tokens[i + 1] !== undefined && !tokens[i + 1]!.startsWith('-')) {
60
- i++;
62
+ const nextToken = tokens[i + 1];
63
+ if (!t.includes('=') && nextToken !== undefined && !nextToken.startsWith('-')) {
64
+ i += 1;
61
65
  }
62
66
  continue;
63
67
  }
@@ -70,18 +74,20 @@ const subcommandTokens = (seg: Segment): string[] => {
70
74
  // This is more powerful than simple regex because it uses the same
71
75
  // Parsing logic as the rest of tripwire.
72
76
  const matchPattern = (segments: readonly Segment[], rule: BlockRule): boolean => {
73
- const pattern = rule.pattern;
77
+ const { pattern } = rule;
74
78
  const patternSegs = parseCommand(pattern);
75
79
  if (patternSegs.length === 0) {
76
80
  return false;
77
81
  }
78
82
 
79
- const patternTokens = patternSegs[0]!.tokens;
80
- const patternHead = patternTokens[0];
83
+ const [patternSegment] = patternSegs;
84
+ if (patternSegment === undefined) {
85
+ return false;
86
+ }
87
+ const [patternHead, ...patternSubcommands] = patternSegment.tokens;
81
88
  if (patternHead === undefined) {
82
89
  return false;
83
90
  }
84
- const patternSubcommands = patternTokens.slice(1);
85
91
 
86
92
  for (const seg of segments) {
87
93
  if (basename(seg.head) !== basename(patternHead)) {
@@ -38,7 +38,7 @@ const TEST_PATH_RE =
38
38
 
39
39
  // Comment-syntax-agnostic. Works in `//`, `#`, `--`, `/* */`, `<!-- -->`,
40
40
  // `;`, `%`, etc.
41
- const BYPASS_RE = /tripwire-allow\b/;
41
+ const BYPASS_RE = /tripwire-allow:[ \t]*\S/;
42
42
 
43
43
  const matches = (line: string): boolean => {
44
44
  if (BYPASS_RE.test(line)) {
@@ -1,16 +1,18 @@
1
- // oxlint-disable-next-line unicorn/import-style
2
- import { resolve } from 'node:path';
1
+ import { lstatSync, readlinkSync, realpathSync } from 'node:fs';
2
+ import path from 'node:path';
3
3
 
4
4
  import { type Decision, allow, deny } from '../lib/decision';
5
5
  import type { EditInput, WriteInput } from '../lib/event';
6
6
 
7
- interface Spec {
7
+ interface ProtectedPathSpec {
8
8
  readonly pattern: RegExp;
9
9
  readonly rule: string;
10
10
  readonly message: string;
11
11
  }
12
12
 
13
- const protections: readonly Spec[] = [
13
+ type PathAccess = 'read' | 'write';
14
+
15
+ const protections: readonly ProtectedPathSpec[] = [
14
16
  {
15
17
  pattern: /(?<prefix>^|\/)\.env(?<ext>\.[^/]+)?$/,
16
18
  rule: 'env-file',
@@ -55,14 +57,75 @@ const protections: readonly Spec[] = [
55
57
  },
56
58
  ];
57
59
 
58
- const pathProtect = (input: EditInput | WriteInput): Decision => {
59
- const path = resolve(input.file_path);
60
- for (const p of protections) {
61
- if (p.pattern.test(path)) {
62
- return deny(p.rule, p.message);
60
+ const resolveExistingPath = (absolutePath: string): string | null => {
61
+ try {
62
+ return realpathSync(absolutePath);
63
+ } catch {
64
+ return null;
65
+ }
66
+ };
67
+
68
+ // A write target may not exist yet. Resolve the deepest existing parent and
69
+ // append the missing suffix. If an existing component is a dangling symlink,
70
+ // follow its link text before continuing so `alias -> .env` cannot hide a new
71
+ // protected target.
72
+ const resolveWritePath = (absolutePath: string, seen: Set<string> = new Set<string>()): string => {
73
+ if (seen.has(absolutePath)) {
74
+ return absolutePath;
75
+ }
76
+ seen.add(absolutePath);
77
+
78
+ const existing = resolveExistingPath(absolutePath);
79
+ if (existing !== null) {
80
+ return existing;
81
+ }
82
+
83
+ try {
84
+ if (lstatSync(absolutePath).isSymbolicLink()) {
85
+ const target = readlinkSync(absolutePath);
86
+ return resolveWritePath(path.resolve(path.dirname(absolutePath), target), seen);
63
87
  }
88
+ } catch {
89
+ // The target does not exist. Resolve its parent below.
90
+ }
91
+
92
+ const parent = path.dirname(absolutePath);
93
+ if (parent === absolutePath) {
94
+ return absolutePath;
95
+ }
96
+ return path.join(resolveWritePath(parent, seen), path.basename(absolutePath));
97
+ };
98
+
99
+ const classifyProtectedPath = (
100
+ submittedPath: string,
101
+ access: PathAccess,
102
+ specs: readonly ProtectedPathSpec[],
103
+ ): ProtectedPathSpec | null => {
104
+ const absolutePath = path.resolve(submittedPath);
105
+ const resolvedPath =
106
+ access === 'write' ? resolveWritePath(absolutePath) : resolveExistingPath(absolutePath);
107
+ const candidates = [submittedPath, absolutePath];
108
+ if (resolvedPath !== null && !candidates.includes(resolvedPath)) {
109
+ candidates.push(resolvedPath);
110
+ }
111
+
112
+ for (const candidate of candidates) {
113
+ for (const spec of specs) {
114
+ if (spec.pattern.test(candidate)) {
115
+ return spec;
116
+ }
117
+ }
118
+ }
119
+ return null;
120
+ };
121
+
122
+ const pathProtect = (input: EditInput | WriteInput): Decision => {
123
+ const protection = classifyProtectedPath(input.file_path, 'write', protections);
124
+ if (protection !== null) {
125
+ return deny(protection.rule, protection.message);
64
126
  }
65
127
  return allow('path-protect');
66
128
  };
67
129
 
68
- export { pathProtect };
130
+ export type { PathAccess, ProtectedPathSpec };
131
+ export { classifyProtectedPath, pathProtect };
@@ -1,6 +1,7 @@
1
+ import type { SecretScannerConfig } from '../lib/config';
1
2
  import { type Decision, allow, deny } from '../lib/decision';
2
3
  import { extractResponseText } from '../lib/event';
3
- import { scanAndRedact } from '../lib/secrets';
4
+ import { scanAndRedact, type ScannerRunner, type ScanFailureCategory } from '../lib/secrets';
4
5
 
5
6
  // PostToolUse: scan whatever string content a tool returned (Bash stdout,
6
7
  // Read content) for known secret patterns via betterleaks. If anything
@@ -11,25 +12,35 @@ import { scanAndRedact } from '../lib/secrets';
11
12
  interface PostInput {
12
13
  readonly toolName: string;
13
14
  readonly response: unknown;
15
+ readonly secretScanner: SecretScannerConfig;
16
+ readonly scannerRunner?: ScannerRunner;
14
17
  }
15
18
 
19
+ const scannerFailureMessage = (category: ScanFailureCategory): string =>
20
+ `Tripwire could not verify this tool output because the configured secret scanner failed ` +
21
+ `(${category}). The original output was withheld. Install Betterleaks 1.5.0 or later, ` +
22
+ `check secretScanner.executable and secretScanner.timeoutMs, then run the original tool again.`;
23
+
16
24
  const postSecretScrub = (input: PostInput): Decision => {
17
25
  const text = extractResponseText(input.toolName, input.response);
18
26
  if (text.length === 0) {
19
27
  return allow('post-secret-scrub');
20
28
  }
21
- const { hits, redacted } = scanAndRedact(text);
22
- if (hits.length === 0) {
29
+ const result = scanAndRedact(text, input.secretScanner, input.scannerRunner);
30
+ if (!result.ok) {
31
+ return deny('secret-scanner-failed', scannerFailureMessage(result.category));
32
+ }
33
+ if (result.hits.length === 0) {
23
34
  return allow('post-secret-scrub');
24
35
  }
25
- const summary = hits.map((h) => `${h.rule}×${h.count}`).join(', ');
36
+ const summary = result.hits.map((hit) => `${hit.rule}×${hit.count}`).join(', ');
26
37
  return deny(
27
38
  'secrets-in-output',
28
39
  [
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.`,
40
+ `tripwire intercepted ${result.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
41
  ``,
31
42
  `Redacted output:`,
32
- redacted.slice(0, 16_000) + (redacted.length > 16_000 ? '\n…[truncated]' : ''),
43
+ result.redacted.slice(0, 16_000) + (result.redacted.length > 16_000 ? '\n…[truncated]' : ''),
33
44
  ].join('\n'),
34
45
  );
35
46
  };
@@ -1,16 +1,8 @@
1
- // oxlint-disable-next-line unicorn/import-style
2
- import { resolve } from 'node:path';
3
-
4
1
  import { type Decision, allow, deny } from '../lib/decision';
5
2
  import type { ReadInput } from '../lib/event';
3
+ import { classifyProtectedPath, type ProtectedPathSpec } from './path-protect';
6
4
 
7
- interface Spec {
8
- readonly rule: string;
9
- readonly pattern: RegExp;
10
- readonly message: string;
11
- }
12
-
13
- const PROTECTIONS: readonly Spec[] = [
5
+ const PROTECTIONS: readonly ProtectedPathSpec[] = [
14
6
  {
15
7
  rule: 'read-env',
16
8
  pattern: /(?<prefix>^|\/)\.env(?<ext>\.[^/]+)?$/,
@@ -55,11 +47,9 @@ const PROTECTIONS: readonly Spec[] = [
55
47
  ];
56
48
 
57
49
  const readProtect = (input: ReadInput): Decision => {
58
- const path = resolve(input.file_path);
59
- for (const p of PROTECTIONS) {
60
- if (p.pattern.test(path)) {
61
- return deny(p.rule, p.message);
62
- }
50
+ const protection = classifyProtectedPath(input.file_path, 'read', PROTECTIONS);
51
+ if (protection !== null) {
52
+ return deny(protection.rule, protection.message);
63
53
  }
64
54
  return allow('read-protect');
65
55
  };
@@ -0,0 +1,54 @@
1
+ import { type Segment, hasBypass } from '../lib/bash';
2
+ import type { ToolPolicy } from '../lib/config';
3
+ import { type Decision, allow, deny, warn } from '../lib/decision';
4
+
5
+ const hasShortFlag = (flags: readonly string[], expected: string): boolean =>
6
+ flags.some(
7
+ (flag) => flag.startsWith('-') && !flag.startsWith('--') && flag.slice(1).includes(expected),
8
+ );
9
+
10
+ const matches = (segment: Segment, policy: ToolPolicy): boolean => {
11
+ if (!policy.executables.includes(segment.head)) {
12
+ return false;
13
+ }
14
+
15
+ const commandArguments = segment.tokens.slice(1);
16
+ const commandArgumentSet = new Set(commandArguments);
17
+ if (
18
+ !(policy.match?.argumentsIncludeAll ?? []).every((argument) => commandArgumentSet.has(argument))
19
+ ) {
20
+ return false;
21
+ }
22
+ if (
23
+ !(policy.match?.argumentsStartWith ?? []).every(
24
+ (argument, index) => commandArguments[index] === argument,
25
+ )
26
+ ) {
27
+ return false;
28
+ }
29
+ return (policy.match?.shortFlagsIncludeAll ?? []).every((flag) =>
30
+ hasShortFlag(segment.flags, flag),
31
+ );
32
+ };
33
+
34
+ const toolPolicy = (
35
+ segments: readonly Segment[],
36
+ command: string,
37
+ policies: readonly ToolPolicy[],
38
+ ): Decision => {
39
+ if (hasBypass(command)) {
40
+ return allow('tool-policy');
41
+ }
42
+ for (const segment of segments) {
43
+ for (const policy of policies) {
44
+ if (matches(segment, policy)) {
45
+ return policy.action === 'deny'
46
+ ? deny(policy.rule, policy.message)
47
+ : warn(policy.rule, policy.message);
48
+ }
49
+ }
50
+ }
51
+ return allow('tool-policy');
52
+ };
53
+
54
+ export { toolPolicy };
@@ -1,11 +0,0 @@
1
- #!/usr/bin/env bun
2
- // @bun @bytecode @bun-cjs
3
- (function(exports, require, module, __filename, __dirname) {var S=require("path"),U=require("@effect/platform-bun"),C=globalThis.Bun,Q=require("effect"),x=require("effect/unstable/cli");var W={name:"@seanmozeik/tripwire",version:"0.6.6",description:"Opinionated hooks dispatcher for AI coding agents with configurable safety rules",license:"MIT",bin:{tripwire:"./dist/tripwire-cli.js","tripwire-hook":"./dist/tripwire.js"},files:["dist","package.json","src","README.md"],type:"module",exports:{".":"./src/index.ts"},publishConfig:{access:"public"},scripts:{build:"bun scripts/build.ts",prepublishOnly:"bun run build",check:"bun run format && bun run lint:fix && bun run typecheck",format:"oxfmt --write .",lint:"oxlint --tsconfig tsconfig.oxlint.json .","lint:fix":"oxlint --format agent --tsconfig tsconfig.oxlint.json --fix .",test:"bun test",typecheck:"tsc --noEmit"},dependencies:{"@effect/platform-bun":"^4.0.0-beta.78",effect:"^4.0.0-beta.78","shell-quote":"^1.8.4"},devDependencies:{"@types/bun":"^1.3.14","@types/shell-quote":"^1.7.5",oxfmt:"^0.53.0",oxlint:"^1.68.0","oxlint-tsgolint":"^0.23.0",typescript:"^6.0.3"},engines:{bun:">=1.0"}};var N=require("os"),z=globalThis.Bun,X="tripwire-hook",$=(b)=>{if(!b)return[[{hooks:[{type:"command",command:X}]}],!1];let w=!1,q=b.map((L)=>({hooks:L.hooks.map((D)=>{if(D.command===X||D.command.endsWith("/tripwire-hook")){if(D.command!==X)return w=!0,{...D,command:X};return D}return D})}));if(q.some((L)=>L.hooks.some((D)=>D.command===X)))return[q,!w];return[[...q,{hooks:[{type:"command",command:X}]}],!1]},v=async()=>{let b=`${N.homedir()}/.claude/settings.json`,w=z.file(b);try{let q=await w.text(),y=JSON.parse(q);y.hooks??={};let[G,L]=$(y.hooks.PreToolUse),[D,j]=$(y.hooks.PostToolUse);if(y.hooks.PreToolUse=G,y.hooks.PostToolUse=D,L&&j)return{success:!0,message:`Already configured: ${b}`};return await w.write(`${JSON.stringify(y,null,2)}
4
- `),{success:!0,message:`Updated ${b}`}}catch(q){let y=q instanceof Error?q.message:String(q);if(y.includes("No such file"))return{success:!1,message:`Config file not found: ${b}`};return{success:!1,message:`Failed to update Claude config: ${y}`}}},J=async()=>{let b=`${N.homedir()}/.pi/agent/settings.json`,w=z.file(b);try{let q=await w.text(),y=JSON.parse(q);y.hooks??={};let[G,L]=$(y.hooks.PreToolUse),[D,j]=$(y.hooks.PostToolUse);if(y.hooks.PreToolUse=G,y.hooks.PostToolUse=D,L&&j)return{success:!0,message:`Already configured: ${b}`};return await w.write(`${JSON.stringify(y,null,2)}
5
- `),{success:!0,message:`Updated ${b}`}}catch(q){let y=q instanceof Error?q.message:String(q);if(y.includes("No such file"))return{success:!1,message:`Config file not found: ${b}`};return{success:!1,message:`Failed to update pi config: ${y}`}}},K=async()=>{let b=`${N.homedir()}/.codex/config.toml`,w=`${N.homedir()}/.codex/hooks.json`,q=z.file(w),y=z.file(b),G=!1,L=!1;try{let D=await q.text(),j=JSON.parse(D);j.hooks??={};let[Y,V]=$(j.hooks.PreToolUse),[B,A]=$(j.hooks.PostToolUse);if(j.hooks.PreToolUse=Y,j.hooks.PostToolUse=B,!V||!A)G=!0;let Z=(R)=>{return R?.map((E)=>({hooks:E.hooks.map((M)=>{if(M.command===X&&M.timeout===void 0)return{...M,timeout:10};return M})}))??[]};if(j.hooks.PreToolUse=Z(j.hooks.PreToolUse),j.hooks.PostToolUse=Z(j.hooks.PostToolUse),G)await q.write(`${JSON.stringify(j,null,2)}
6
- `)}catch(D){let j=D instanceof Error?D.message:String(D);if(j.includes("No such file"))return{success:!1,message:`Config file not found: ${w}`};return{success:!1,message:`Failed to update Codex hooks.json: ${j}`}}try{let j=await y.text();if(j.includes("hooks = true"));else if(L=!0,j.includes("[features]")){let Y=j.indexOf("[features]"),V=j.indexOf(`
7
- [`,Y+1);if(V===-1)j+=`
8
- hooks = true`;else j=`${j.slice(0,V)}
9
- hooks = true${j.slice(V)}`}else j+=`
10
- [features]
11
- hooks = true`;if(L)await y.write(j)}catch(D){let j=D instanceof Error?D.message:String(D);if(j.includes("No such file"))return{success:!1,message:`Config file not found: ${b}`};return{success:!1,message:`Failed to update Codex config.toml: ${j}`}}if(!G&&!L)return{success:!0,message:`Already configured: ${b} and ${w}`};return{success:!0,message:`Updated ${b} and ${w}`}},_=async()=>{return[{target:"claude",...await v()},{target:"codex",...await K()},{target:"pi",...await J()}]};var O=()=>{return/\/bun(?<ext>\.exe)?$/.test(process.argv[0]??"")?process.argv[1]:process.argv[0]},u=async()=>{let b=O(),w=S.dirname(b),q=`${w}/tripwire-hook`;try{return await C.file(q).text(),q}catch{return`${w}/tripwire.js`}},T=(b,w,q,y)=>{if(b==="Bash")return{command:w??""};if(b==="Read")return{file_path:q??""};if(b==="Write")return{file_path:q??"",content:y??""};if(b==="Edit"||b==="MultiEdit")return{file_path:q??"",old_string:"",new_string:y??""};return},I=(b)=>{let{tool:w,post:q,command:y,path:G,stdout:L,stderr:D,content:j}=b,V={hook_event_name:q?"PostToolUse":"PreToolUse",tool_name:w,cwd:process.cwd(),session_id:"tripwire-cli-test",tool_input:T(w,y,G,j)};if(q)V.tool_response=w==="Bash"?{stdout:L??"",stderr:D??""}:{content:j??""};return V},F=(b)=>Q.Effect.gen(function*(){let{command:w,content:q,path:y,post:G,stderr:L,stdout:D,tool:j}=b,Y=I({tool:j,post:G,command:w,path:y,stdout:D,stderr:L,content:q}),V=yield*Q.Effect.promise(()=>u()),B=Bun.spawnSync([V],{stdin:new TextEncoder().encode(JSON.stringify(Y)),timeout:1e4,stdout:"pipe",stderr:"pipe"});if(B.exitCode!==0){let Z=new TextDecoder().decode(B.stderr);console.error(`error: ${Z}`),process.exit(1)}let A=new TextDecoder().decode(B.stdout);try{let Z=JSON.parse(A);console.log(JSON.stringify(Z,null,2))}catch{console.log(A)}}),P=x.Command.make("test",{command:x.Argument.string("command").pipe(x.Argument.optional,x.Argument.withDescription("Command to test (for Bash tool)")),content:x.Flag.string("content").pipe(x.Flag.optional,x.Flag.withDescription("Content for Write/Edit tools")),path:x.Flag.string("path").pipe(x.Flag.optional,x.Flag.withDescription("File path for Read/Write/Edit tools")),post:x.Flag.boolean("post").pipe(x.Flag.withDescription("Test PostToolUse instead of PreToolUse")),stderr:x.Flag.string("stderr").pipe(x.Flag.optional,x.Flag.withDescription("Stderr for PostToolUse Bash")),stdout:x.Flag.string("stdout").pipe(x.Flag.optional,x.Flag.withDescription("Stdout for PostToolUse Bash")),tool:x.Flag.string("tool").pipe(x.Flag.withDefault("Bash"),x.Flag.withDescription("Tool name (Bash, Read, Write, Edit, MultiEdit)"))},({command:b,content:w,path:q,post:y,stderr:G,stdout:L,tool:D})=>F({command:Q.Option.getOrUndefined(b),content:Q.Option.getOrUndefined(w),path:Q.Option.getOrUndefined(q),post:y,stderr:Q.Option.getOrUndefined(G),stdout:Q.Option.getOrUndefined(L),tool:D})).pipe(x.Command.withDescription("Test a synthetic hook event")),p=(b)=>Q.Effect.gen(function*(){if(!["claude","codex","pi","all"].includes(b))console.error(`error: unknown target "${b}"`),console.error("Valid targets: claude, codex, pi, all"),process.exit(1);let w;switch(b){case"claude":{w=[{target:"claude",result:yield*Q.Effect.promise(()=>v())}];break}case"codex":{w=[{target:"codex",result:yield*Q.Effect.promise(()=>K())}];break}case"pi":{w=[{target:"pi",result:yield*Q.Effect.promise(()=>J())}];break}case"all":{w=(yield*Q.Effect.promise(()=>_())).map((G)=>({target:G.target,result:G}));break}default:{w=[];break}}let q=!1;for(let{target:y,result:G}of w)if(G.success){let L=G.message.startsWith("Already configured")?"\u2299":"\u2713";console.log(`${L} [${y}] ${G.message}`)}else console.error(`\u2717 [${y}] ${G.message}`),q=!0;if(q)process.exit(1)}),k=x.Command.make("install",{target:x.Argument.string("target").pipe(x.Argument.withDescription("Target agent (claude, codex, pi, or all)"))},({target:b})=>p(b)).pipe(x.Command.withDescription("Install tripwire hooks for AI agents")),c=x.Command.make("tripwire").pipe(x.Command.withDescription("Opinionated hooks dispatcher for AI coding agents"),x.Command.withSubcommands([P,k])),f=x.Command.run(c,{version:W.version}),h=async()=>{try{await Q.Effect.runPromise(f.pipe(Q.Effect.provide(U.BunServices.layer)))}catch(b){let w=b instanceof Error?b.message:String(b);console.error(w),process.exitCode=1}};h();})
Binary file