@seanmozeik/tripwire 0.7.0 → 0.7.2
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/README.md +16 -14
- package/dist/index.js +35 -0
- package/dist/tripwire-cli.js +3 -0
- package/dist/tripwire-hook.js +3 -0
- package/dist/tripwire-pi.js +6 -4
- package/dist/tripwire.js +141 -0
- package/dist/types/dispatch.d.ts +18 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/lib/bash.d.ts +27 -0
- package/dist/types/lib/config.d.ts +110 -0
- package/dist/types/lib/cursor.d.ts +16 -0
- package/dist/types/lib/decision.d.ts +13 -0
- package/dist/types/lib/diff.d.ts +3 -0
- package/dist/types/lib/event.d.ts +45 -0
- package/dist/types/lib/log.d.ts +2 -0
- package/dist/types/lib/secrets.d.ts +41 -0
- package/dist/types/rules/bash-deny.d.ts +5 -0
- package/dist/types/rules/bash-git.d.ts +5 -0
- package/dist/types/rules/bash-network-install.d.ts +4 -0
- package/dist/types/rules/bash-redirect.d.ts +4 -0
- package/dist/types/rules/bash-scoped-rm.d.ts +5 -0
- package/dist/types/rules/bash-tar-explosion.d.ts +4 -0
- package/dist/types/rules/config-custom.d.ts +6 -0
- package/dist/types/rules/lazy-code.d.ts +4 -0
- package/dist/types/rules/path-protect.d.ts +12 -0
- package/dist/types/rules/post-secret-scrub.d.ts +12 -0
- package/dist/types/rules/read-protect.d.ts +4 -0
- package/dist/types/rules/tool-policy.d.ts +5 -0
- package/package.json +16 -13
- package/dist/tripwire +0 -0
- package/scripts/tripwire-cli +0 -12
- package/src/cli.ts +0 -271
- package/src/dispatch.ts +0 -562
- package/src/index.ts +0 -6
- package/src/lib/bash.ts +0 -1328
- package/src/lib/config.ts +0 -174
- package/src/lib/cursor.ts +0 -336
- package/src/lib/decision.ts +0 -36
- package/src/lib/diff.ts +0 -29
- package/src/lib/event.ts +0 -105
- package/src/lib/install.ts +0 -610
- package/src/lib/log.ts +0 -23
- package/src/lib/secrets.ts +0 -184
- package/src/main.ts +0 -31
- package/src/pi-extension.ts +0 -337
- package/src/rules/bash-deny.ts +0 -404
- package/src/rules/bash-git.ts +0 -590
- package/src/rules/bash-network-install.ts +0 -75
- package/src/rules/bash-redirect.ts +0 -91
- package/src/rules/bash-scoped-rm.ts +0 -84
- package/src/rules/bash-tar-explosion.ts +0 -77
- package/src/rules/config-custom.ts +0 -166
- package/src/rules/lazy-code.ts +0 -95
- package/src/rules/path-protect.ts +0 -131
- package/src/rules/post-secret-scrub.ts +0 -49
- package/src/rules/read-protect.ts +0 -57
- package/src/rules/tool-policy.ts +0 -54
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import { type Segment, hasBypass } from '../lib/bash';
|
|
2
|
-
import { type Decision, allow, deny } from '../lib/decision';
|
|
3
|
-
import { classifyProtectedPath, type ProtectedPathSpec } from './path-protect';
|
|
4
|
-
|
|
5
|
-
// Block writes (via shell redirect, tee, cp, mv) that target sensitive
|
|
6
|
-
// Files. Catches the exfil-via-redirect gap that path-protect can't see
|
|
7
|
-
// Because it only watches Edit/Write tool calls.
|
|
8
|
-
|
|
9
|
-
const PROTECTED_TARGET_RE: readonly ProtectedPathSpec[] = [
|
|
10
|
-
{
|
|
11
|
-
rule: 'redirect-env',
|
|
12
|
-
pattern: /(?<prefix>^|\/)\.env(?<ext>\.[^/]+)?$/,
|
|
13
|
-
message:
|
|
14
|
-
'Refusing to write into a .env file via shell redirect / tee / cp / mv. .env files hold secrets — never overwrite from a tool call.',
|
|
15
|
-
},
|
|
16
|
-
{
|
|
17
|
-
rule: 'redirect-dev-vars',
|
|
18
|
-
pattern: /(?<prefix>^|\/)\.dev\.vars(?<ext>\.[^/]+)?$/,
|
|
19
|
-
message: 'Refusing to write into .dev.vars (Cloudflare/Wrangler secrets).',
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
rule: 'redirect-ssh',
|
|
23
|
-
pattern: /(?<prefix>^|\/)\.ssh\//,
|
|
24
|
-
message: 'Refusing to write into ~/.ssh/ via shell.',
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
rule: 'redirect-key',
|
|
28
|
-
pattern: /\.(?<ext>pem|key|p12|pfx)$/i,
|
|
29
|
-
message: 'Refusing to overwrite a private-key-shaped file via shell.',
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
rule: 'redirect-aws-credentials',
|
|
33
|
-
pattern: /(?<prefix>^|\/)\.aws\/credentials$/,
|
|
34
|
-
message: 'Refusing to write into ~/.aws/credentials via shell.',
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
rule: 'redirect-netrc',
|
|
38
|
-
pattern: /(?<prefix>^|\/)\.netrc$/,
|
|
39
|
-
message: 'Refusing to write into ~/.netrc via shell.',
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
rule: 'redirect-block-device',
|
|
43
|
-
pattern: /^\/dev\/(?<type>sd|disk|nvme|rdisk)/i,
|
|
44
|
-
message: 'Redirecting into a raw block device wipes the disk. Refuse.',
|
|
45
|
-
},
|
|
46
|
-
];
|
|
47
|
-
|
|
48
|
-
const checkPath = (path: string): Decision | null => {
|
|
49
|
-
const protection = classifyProtectedPath(path, 'write', PROTECTED_TARGET_RE);
|
|
50
|
-
if (protection !== null) {
|
|
51
|
-
return deny(protection.rule, protection.message);
|
|
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 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
|
-
);
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
export { bashScopedRm };
|
|
@@ -1,77 +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 = (flag: string): boolean =>
|
|
8
|
-
flag === '--extract' ||
|
|
9
|
-
(flag.startsWith('-') && !flag.startsWith('--') && flag.slice(1).includes('x'));
|
|
10
|
-
|
|
11
|
-
const findChangeDir = (seg: Segment): string | null => {
|
|
12
|
-
for (let i = 0; i < seg.tokens.length; i += 1) {
|
|
13
|
-
const t = seg.tokens[i];
|
|
14
|
-
if (t === undefined) {
|
|
15
|
-
continue;
|
|
16
|
-
}
|
|
17
|
-
if (t === '-C' || t === '--directory') {
|
|
18
|
-
return seg.tokens[i + 1] ?? null;
|
|
19
|
-
}
|
|
20
|
-
if (t.startsWith('--directory=')) {
|
|
21
|
-
return t.slice('--directory='.length);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
return null;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
const isUnsafeExtractDest = (dest: string): boolean => {
|
|
28
|
-
return dest === '/' || /^(?<home>~|\$HOME|\$\{HOME\})$/.test(dest);
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const bashTarExplosion = (segments: readonly Segment[], cmd: string): Decision => {
|
|
32
|
-
if (hasBypass(cmd)) {
|
|
33
|
-
return allow('bash-tar-explosion');
|
|
34
|
-
}
|
|
35
|
-
for (const seg of segments) {
|
|
36
|
-
if (seg.head !== 'tar') {
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
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'));
|
|
46
|
-
if (!extracting) {
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
const dest = findChangeDir(seg);
|
|
50
|
-
if (dest !== null && isUnsafeExtractDest(dest)) {
|
|
51
|
-
return deny(
|
|
52
|
-
'tar-extract-to-root',
|
|
53
|
-
`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.`,
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
// Unzip with -d destination
|
|
58
|
-
for (const seg of segments) {
|
|
59
|
-
if (seg.head !== 'unzip') {
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
for (let i = 0; i < seg.tokens.length; i += 1) {
|
|
63
|
-
if (seg.tokens[i] === '-d') {
|
|
64
|
-
const dest = seg.tokens[i + 1];
|
|
65
|
-
if (dest !== undefined && isUnsafeExtractDest(dest)) {
|
|
66
|
-
return deny(
|
|
67
|
-
'unzip-to-root',
|
|
68
|
-
`unzip -d ${dest} can overwrite arbitrary system files. Refuse — extract to a contained directory.`,
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return allow('bash-tar-explosion');
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
export { bashTarExplosion };
|
|
@@ -1,166 +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
|
-
// Match command policy by executable basename so an absolute path cannot
|
|
23
|
-
// bypass a configured rule.
|
|
24
|
-
const basename = (token: string): string => {
|
|
25
|
-
const idx = token.lastIndexOf('/');
|
|
26
|
-
return idx === -1 ? token : token.slice(idx + 1);
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const flagPresent = (tokens: readonly string[], flag: string): boolean =>
|
|
30
|
-
tokens.some((t) => t === flag || t.startsWith(`${flag}=`));
|
|
31
|
-
|
|
32
|
-
const flagValue = (tokens: readonly string[], flag: string): string | null => {
|
|
33
|
-
for (let i = 0; i < tokens.length; i += 1) {
|
|
34
|
-
const t = tokens[i];
|
|
35
|
-
if (t === undefined) {
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
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 += 1) {
|
|
52
|
-
const t = tokens[i];
|
|
53
|
-
if (t === undefined) {
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
56
|
-
if (t.startsWith('-')) {
|
|
57
|
-
// Without per-CLI flag metadata, we conservatively treat
|
|
58
|
-
// `--flag value` / `-f value` as one option pair and `--flag=value`
|
|
59
|
-
// As one token. This keeps global selectors like `--account X`
|
|
60
|
-
// Out of the subcommand path, at the cost of not distinguishing
|
|
61
|
-
// Boolean flags that precede positional args.
|
|
62
|
-
const nextToken = tokens[i + 1];
|
|
63
|
-
if (!t.includes('=') && nextToken !== undefined && !nextToken.startsWith('-')) {
|
|
64
|
-
i += 1;
|
|
65
|
-
}
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
out.push(t);
|
|
69
|
-
}
|
|
70
|
-
return out;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
// Match a pattern against parsed segments using shell parsing.
|
|
74
|
-
// This is more powerful than simple regex because it uses the same
|
|
75
|
-
// Parsing logic as the rest of tripwire.
|
|
76
|
-
const matchPattern = (segments: readonly Segment[], rule: BlockRule): boolean => {
|
|
77
|
-
const { pattern } = rule;
|
|
78
|
-
const patternSegs = parseCommand(pattern);
|
|
79
|
-
if (patternSegs.length === 0) {
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const [patternSegment] = patternSegs;
|
|
84
|
-
if (patternSegment === undefined) {
|
|
85
|
-
return false;
|
|
86
|
-
}
|
|
87
|
-
const [patternHead, ...patternSubcommands] = patternSegment.tokens;
|
|
88
|
-
if (patternHead === undefined) {
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
for (const seg of segments) {
|
|
93
|
-
if (basename(seg.head) !== basename(patternHead)) {
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (patternSubcommands.length > 0) {
|
|
98
|
-
const actualSubcommands = subcommandTokens(seg);
|
|
99
|
-
const pathMatches = patternSubcommands.every(
|
|
100
|
-
(p, i) =>
|
|
101
|
-
actualSubcommands[i] !== undefined && canonical(actualSubcommands[i]) === canonical(p),
|
|
102
|
-
);
|
|
103
|
-
if (!pathMatches) {
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if ((rule.requiresFlags ?? []).some((flag) => !flagPresent(seg.tokens, flag))) {
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const valueChecks = rule.forbidsFlagValues ?? [];
|
|
113
|
-
const valuesMatch = valueChecks.every((check) => {
|
|
114
|
-
const value = flagValue(seg.tokens, check.flag);
|
|
115
|
-
return value !== null && check.values.includes(value);
|
|
116
|
-
});
|
|
117
|
-
if (!valuesMatch) {
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
patternSubcommands.length === 0 &&
|
|
123
|
-
rule.requiresFlags === undefined &&
|
|
124
|
-
rule.forbidsFlagValues === undefined
|
|
125
|
-
) {
|
|
126
|
-
return true;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return true;
|
|
130
|
-
}
|
|
131
|
-
return false;
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
export const configCustom = (
|
|
135
|
-
segments: readonly Segment[],
|
|
136
|
-
cmd: string,
|
|
137
|
-
blockedCommands: readonly BlockRule[],
|
|
138
|
-
allowedCommands: readonly BlockRule[],
|
|
139
|
-
): Decision => {
|
|
140
|
-
if (hasBypass(cmd)) {
|
|
141
|
-
return allow('config-custom');
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Check allowed first (overrides blocks)
|
|
145
|
-
for (const allowRule of allowedCommands) {
|
|
146
|
-
if (matchPattern(segments, allowRule)) {
|
|
147
|
-
return allow('config-custom');
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Then check blocked
|
|
152
|
-
for (const blockRule of blockedCommands) {
|
|
153
|
-
if (matchPattern(segments, blockRule)) {
|
|
154
|
-
const message = blockRule.message.includes('tripwire-allow')
|
|
155
|
-
? blockRule.message
|
|
156
|
-
: `${blockRule.message} ${BYPASS_HELP}`;
|
|
157
|
-
return blockRule.action === 'ask'
|
|
158
|
-
? ask('config-custom', message)
|
|
159
|
-
: deny('config-custom', message);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
return allow('config-custom');
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
export { matchPattern };
|
package/src/rules/lazy-code.ts
DELETED
|
@@ -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:[ \t]*\S/;
|
|
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 };
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import { lstatSync, readlinkSync, realpathSync } from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
|
|
4
|
-
import { type Decision, allow, deny } from '../lib/decision';
|
|
5
|
-
import type { EditInput, WriteInput } from '../lib/event';
|
|
6
|
-
|
|
7
|
-
interface ProtectedPathSpec {
|
|
8
|
-
readonly pattern: RegExp;
|
|
9
|
-
readonly rule: string;
|
|
10
|
-
readonly message: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
type PathAccess = 'read' | 'write';
|
|
14
|
-
|
|
15
|
-
const protections: readonly ProtectedPathSpec[] = [
|
|
16
|
-
{
|
|
17
|
-
pattern: /(?<prefix>^|\/)\.env(?<ext>\.[^/]+)?$/,
|
|
18
|
-
rule: 'env-file',
|
|
19
|
-
message:
|
|
20
|
-
'.env files hold secrets that should never be sent to the model. Refuse to write or edit. If an example is needed, create .env.example with redacted placeholders.',
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
pattern: /(?<prefix>^|\/)\.dev\.vars(?<ext>\.[^/]+)?$/,
|
|
24
|
-
rule: 'dev-vars',
|
|
25
|
-
message: '.dev.vars holds Cloudflare/Wrangler secrets. Do not modify.',
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
pattern: /(?<prefix>^|\/)\.ssh\//,
|
|
29
|
-
rule: 'ssh-dir',
|
|
30
|
-
message: 'Never write into ~/.ssh/. Refuse.',
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
pattern: /(?<prefix>^|\/)(?<key>id_rsa|id_ed25519|id_ecdsa|id_dsa)(?<pub>\.pub)?$/,
|
|
34
|
-
rule: 'ssh-key',
|
|
35
|
-
message: 'SSH key file. Refuse.',
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
pattern: /\.(?<ext>pem|key|p12|pfx)$/i,
|
|
39
|
-
rule: 'private-key',
|
|
40
|
-
message:
|
|
41
|
-
'Private key file. Refuse to overwrite. If generating a new key, use a different filename and let the user review.',
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
pattern: /(?<prefix>^|\/)secrets?\.(?<ext>json|ya?ml|toml|env)$/i,
|
|
45
|
-
rule: 'secrets-file',
|
|
46
|
-
message: 'Secrets file. Refuse.',
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
pattern: /(?<prefix>^|\/)\.aws\/credentials$/,
|
|
50
|
-
rule: 'aws-credentials',
|
|
51
|
-
message: 'AWS credentials file. Refuse.',
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
pattern: /(?<prefix>^|\/)\.netrc$/,
|
|
55
|
-
rule: 'netrc',
|
|
56
|
-
message: '.netrc holds host credentials. Refuse.',
|
|
57
|
-
},
|
|
58
|
-
];
|
|
59
|
-
|
|
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);
|
|
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);
|
|
126
|
-
}
|
|
127
|
-
return allow('path-protect');
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
export type { PathAccess, ProtectedPathSpec };
|
|
131
|
-
export { classifyProtectedPath, pathProtect };
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import type { SecretScannerConfig } from '../lib/config';
|
|
2
|
-
import { type Decision, allow, deny } from '../lib/decision';
|
|
3
|
-
import { extractResponseText } from '../lib/event';
|
|
4
|
-
import { scanAndRedact, type ScannerRunner, type ScanFailureCategory } from '../lib/secrets';
|
|
5
|
-
|
|
6
|
-
// PostToolUse: scan whatever string content a tool returned (Bash stdout,
|
|
7
|
-
// Read content) for known secret patterns via betterleaks. If anything
|
|
8
|
-
// Fires, block the result (so the original output never reaches the
|
|
9
|
-
// Model) and surface a redacted version in the block reason — that lets
|
|
10
|
-
// The agent see what was returned without leaking the secret itself.
|
|
11
|
-
|
|
12
|
-
interface PostInput {
|
|
13
|
-
readonly toolName: string;
|
|
14
|
-
readonly response: unknown;
|
|
15
|
-
readonly secretScanner: SecretScannerConfig;
|
|
16
|
-
readonly scannerRunner?: ScannerRunner;
|
|
17
|
-
}
|
|
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
|
-
|
|
24
|
-
const postSecretScrub = (input: PostInput): Decision => {
|
|
25
|
-
const text = extractResponseText(input.toolName, input.response);
|
|
26
|
-
if (text.length === 0) {
|
|
27
|
-
return allow('post-secret-scrub');
|
|
28
|
-
}
|
|
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) {
|
|
34
|
-
return allow('post-secret-scrub');
|
|
35
|
-
}
|
|
36
|
-
const summary = result.hits.map((hit) => `${hit.rule}×${hit.count}`).join(', ');
|
|
37
|
-
return deny(
|
|
38
|
-
'secrets-in-output',
|
|
39
|
-
[
|
|
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.`,
|
|
41
|
-
``,
|
|
42
|
-
`Redacted output:`,
|
|
43
|
-
result.redacted.slice(0, 16_000) + (result.redacted.length > 16_000 ? '\n…[truncated]' : ''),
|
|
44
|
-
].join('\n'),
|
|
45
|
-
);
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
export type { PostInput };
|
|
49
|
-
export { postSecretScrub };
|