@seanmozeik/tripwire 0.6.7 → 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.
- package/LICENSE +21 -0
- package/README.md +152 -141
- package/dist/tripwire +0 -0
- package/dist/tripwire-pi.js +4 -0
- package/package.json +50 -22
- package/scripts/tripwire-cli +12 -0
- package/src/cli.ts +64 -57
- package/src/dispatch.ts +322 -114
- package/src/index.ts +1 -1
- package/src/lib/bash.ts +106 -62
- package/src/lib/config.ts +93 -46
- package/src/lib/cursor.ts +336 -0
- package/src/lib/diff.ts +5 -2
- package/src/lib/event.ts +20 -21
- package/src/lib/install.ts +508 -136
- package/src/lib/log.ts +2 -3
- package/src/lib/secrets.ts +151 -88
- package/src/main.ts +31 -0
- package/src/pi-extension.ts +337 -0
- package/src/rules/bash-deny.ts +13 -3
- package/src/rules/bash-git.ts +32 -45
- package/src/rules/bash-network-install.ts +6 -3
- package/src/rules/bash-redirect.ts +5 -5
- package/src/rules/bash-scoped-rm.ts +1 -1
- package/src/rules/bash-tar-explosion.ts +16 -15
- package/src/rules/config-custom.ts +21 -15
- package/src/rules/lazy-code.ts +1 -1
- package/src/rules/path-protect.ts +73 -10
- package/src/rules/post-secret-scrub.ts +17 -6
- package/src/rules/read-protect.ts +5 -15
- package/src/rules/tool-policy.ts +54 -0
- package/dist/tripwire-cli.js +0 -11
- package/dist/tripwire-cli.js.jsc +0 -0
- package/dist/tripwire.js +0 -96
- package/dist/tripwire.js.jsc +0 -0
- package/src/rules/bash-tool-policy.ts +0 -146
package/src/rules/bash-git.ts
CHANGED
|
@@ -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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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((
|
|
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
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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 = (
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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
|
-
//
|
|
23
|
-
//
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
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
|
|
80
|
-
|
|
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)) {
|
package/src/rules/lazy-code.ts
CHANGED
|
@@ -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\
|
|
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
|
-
|
|
2
|
-
import
|
|
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
|
|
7
|
+
interface ProtectedPathSpec {
|
|
8
8
|
readonly pattern: RegExp;
|
|
9
9
|
readonly rule: string;
|
|
10
10
|
readonly message: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 {
|
|
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
|
|
22
|
-
if (
|
|
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((
|
|
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
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
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 };
|
package/dist/tripwire-cli.js
DELETED
|
@@ -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"),q=require("effect/unstable/cli");var W={name:"@seanmozeik/tripwire",version:"0.6.7",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 .",prepare:"bunx effect-tsgo patch",test:"bun test",typecheck:"tsc --noEmit"},dependencies:{"shell-quote":"^1.8.4"},devDependencies:{"@effect/platform-bun":"^4.0.0-beta.102","@effect/tsgo":"^0.24.3","@types/bun":"^1.3.14","@types/shell-quote":"^1.7.5",effect:"^4.0.0-beta.102",oxfmt:"^0.53.0",oxlint:"1.68.0","oxlint-tsgolint":"^0.23.0",typescript:"^7.0.2"},peerDependencies:{"@effect/platform-bun":"^4.0.0-beta.102",effect:"^4.0.0-beta.102"},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,x=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(x.some((L)=>L.hooks.some((D)=>D.command===X)))return[x,!w];return[[...x,{hooks:[{type:"command",command:X}]}],!1]},v=async()=>{let b=`${N.homedir()}/.claude/settings.json`,w=z.file(b);try{let x=await w.text(),y=JSON.parse(x);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(x){let y=x instanceof Error?x.message:String(x);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 x=await w.text(),y=JSON.parse(x);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(x){let y=x instanceof Error?x.message:String(x);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`,x=z.file(w),y=z.file(b),G=!1,L=!1;try{let D=await x.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 x.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),x=`${w}/tripwire-hook`;try{return await C.file(x).text(),x}catch{return`${w}/tripwire.js`}},T=(b,w,x,y)=>{if(b==="Bash")return{command:w??""};if(b==="Read")return{file_path:x??""};if(b==="Write")return{file_path:x??"",content:y??""};if(b==="Edit"||b==="MultiEdit")return{file_path:x??"",old_string:"",new_string:y??""};return},I=(b)=>{let{tool:w,post:x,command:y,path:G,stdout:L,stderr:D,content:j}=b,V={hook_event_name:x?"PostToolUse":"PreToolUse",tool_name:w,cwd:process.cwd(),session_id:"tripwire-cli-test",tool_input:T(w,y,G,j)};if(x)V.tool_response=w==="Bash"?{stdout:L??"",stderr:D??""}:{content:j??""};return V},F=(b)=>Q.Effect.gen(function*(){let{command:w,content:x,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:x}),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=q.Command.make("test",{command:q.Argument.string("command").pipe(q.Argument.optional,q.Argument.withDescription("Command to test (for Bash tool)")),content:q.Flag.string("content").pipe(q.Flag.optional,q.Flag.withDescription("Content for Write/Edit tools")),path:q.Flag.string("path").pipe(q.Flag.optional,q.Flag.withDescription("File path for Read/Write/Edit tools")),post:q.Flag.boolean("post").pipe(q.Flag.withDescription("Test PostToolUse instead of PreToolUse")),stderr:q.Flag.string("stderr").pipe(q.Flag.optional,q.Flag.withDescription("Stderr for PostToolUse Bash")),stdout:q.Flag.string("stdout").pipe(q.Flag.optional,q.Flag.withDescription("Stdout for PostToolUse Bash")),tool:q.Flag.string("tool").pipe(q.Flag.withDefault("Bash"),q.Flag.withDescription("Tool name (Bash, Read, Write, Edit, MultiEdit)"))},({command:b,content:w,path:x,post:y,stderr:G,stdout:L,tool:D})=>F({command:Q.Option.getOrUndefined(b),content:Q.Option.getOrUndefined(w),path:Q.Option.getOrUndefined(x),post:y,stderr:Q.Option.getOrUndefined(G),stdout:Q.Option.getOrUndefined(L),tool:D})).pipe(q.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 x=!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}`),x=!0;if(x)process.exit(1)}),c=q.Command.make("install",{target:q.Argument.string("target").pipe(q.Argument.withDescription("Target agent (claude, codex, pi, or all)"))},({target:b})=>p(b)).pipe(q.Command.withDescription("Install tripwire hooks for AI agents")),k=q.Command.make("tripwire").pipe(q.Command.withDescription("Opinionated hooks dispatcher for AI coding agents"),q.Command.withSubcommands([P,c])),f=q.Command.run(k,{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();})
|
package/dist/tripwire-cli.js.jsc
DELETED
|
Binary file
|