@seanmozeik/tripwire 0.6.7 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -141
  3. package/dist/index.js +35 -0
  4. package/dist/tripwire-cli.js +2 -10
  5. package/dist/tripwire-hook.js +3 -0
  6. package/dist/tripwire-pi.js +4 -0
  7. package/dist/tripwire.js +135 -90
  8. package/dist/types/dispatch.d.ts +18 -0
  9. package/dist/types/index.d.ts +6 -0
  10. package/dist/types/lib/bash.d.ts +27 -0
  11. package/dist/types/lib/config.d.ts +110 -0
  12. package/dist/types/lib/cursor.d.ts +16 -0
  13. package/dist/types/lib/decision.d.ts +13 -0
  14. package/dist/types/lib/diff.d.ts +3 -0
  15. package/dist/types/lib/event.d.ts +45 -0
  16. package/dist/types/lib/log.d.ts +2 -0
  17. package/dist/types/lib/secrets.d.ts +41 -0
  18. package/dist/types/rules/bash-deny.d.ts +5 -0
  19. package/dist/types/rules/bash-git.d.ts +5 -0
  20. package/dist/types/rules/bash-network-install.d.ts +4 -0
  21. package/dist/types/rules/bash-redirect.d.ts +4 -0
  22. package/dist/types/rules/bash-scoped-rm.d.ts +5 -0
  23. package/dist/types/rules/bash-tar-explosion.d.ts +4 -0
  24. package/dist/types/rules/config-custom.d.ts +6 -0
  25. package/dist/types/rules/lazy-code.d.ts +4 -0
  26. package/dist/types/rules/path-protect.d.ts +12 -0
  27. package/dist/types/rules/post-secret-scrub.d.ts +12 -0
  28. package/dist/types/rules/read-protect.d.ts +4 -0
  29. package/dist/types/rules/tool-policy.d.ts +5 -0
  30. package/package.json +53 -22
  31. package/dist/tripwire-cli.js.jsc +0 -0
  32. package/dist/tripwire.js.jsc +0 -0
  33. package/src/cli.ts +0 -264
  34. package/src/dispatch.ts +0 -354
  35. package/src/index.ts +0 -6
  36. package/src/lib/bash.ts +0 -1284
  37. package/src/lib/config.ts +0 -127
  38. package/src/lib/decision.ts +0 -36
  39. package/src/lib/diff.ts +0 -26
  40. package/src/lib/event.ts +0 -106
  41. package/src/lib/install.ts +0 -238
  42. package/src/lib/log.ts +0 -24
  43. package/src/lib/secrets.ts +0 -121
  44. package/src/rules/bash-deny.ts +0 -394
  45. package/src/rules/bash-git.ts +0 -603
  46. package/src/rules/bash-network-install.ts +0 -72
  47. package/src/rules/bash-redirect.ts +0 -91
  48. package/src/rules/bash-scoped-rm.ts +0 -84
  49. package/src/rules/bash-tar-explosion.ts +0 -76
  50. package/src/rules/bash-tool-policy.ts +0 -146
  51. package/src/rules/config-custom.ts +0 -160
  52. package/src/rules/lazy-code.ts +0 -95
  53. package/src/rules/path-protect.ts +0 -68
  54. package/src/rules/post-secret-scrub.ts +0 -38
  55. package/src/rules/read-protect.ts +0 -67
package/src/lib/config.ts DELETED
@@ -1,127 +0,0 @@
1
- // Config system using Effect Schema for validation and Effect for safe loading.
2
- // Config file: ~/.config/tripwire/config.json
3
- // Falls back to defaults if file doesn't exist or is invalid.
4
-
5
- import { accessSync, constants, readFileSync } from 'node:fs';
6
- import { homedir } from 'node:os';
7
-
8
- import { Cause, Effect, Schema } from 'effect';
9
-
10
- const BlockRuleSchema = Schema.Struct({
11
- pattern: Schema.String,
12
- message: Schema.String,
13
- action: Schema.optional(Schema.Union([Schema.Literal('deny'), Schema.Literal('ask')])),
14
- requiresFlags: Schema.optional(Schema.Array(Schema.String)),
15
- forbidsFlagValues: Schema.optional(
16
- Schema.Array(Schema.Struct({ flag: Schema.String, values: Schema.Array(Schema.String) })),
17
- ),
18
- });
19
-
20
- const GitConfigSchema = Schema.Struct({
21
- protectedBranches: Schema.optional(Schema.Array(Schema.String)),
22
- enforceConventionalCommits: Schema.optional(Schema.Boolean),
23
- });
24
-
25
- const SafePathsConfigSchema = Schema.Struct({
26
- relative: Schema.optional(Schema.Array(Schema.String)),
27
- absolute: Schema.optional(Schema.Array(Schema.String)),
28
- });
29
-
30
- const ConfigSchema = Schema.Struct({
31
- git: Schema.optional(GitConfigSchema),
32
- safePaths: Schema.optional(SafePathsConfigSchema),
33
- blockedCommands: Schema.optional(Schema.Array(BlockRuleSchema)),
34
- allowedCommands: Schema.optional(Schema.Array(BlockRuleSchema)),
35
- });
36
-
37
- const CONFIG_PATH = `${homedir()}/.config/tripwire/config.json`;
38
-
39
- const configExists = (path: string): Effect.Effect<boolean> =>
40
- Effect.sync(() => {
41
- try {
42
- accessSync(path, constants.R_OK);
43
- return true;
44
- } catch {
45
- return false;
46
- }
47
- });
48
-
49
- const readConfigFile = (path: string): Effect.Effect<string, Error> =>
50
- Effect.try({ try: () => readFileSync(path, 'utf8'), catch: (error) => error as Error });
51
-
52
- const parseConfigJson = (raw: string): Effect.Effect<unknown, Error> =>
53
- Effect.try({ try: () => JSON.parse(raw) as unknown, catch: (error) => error as Error });
54
-
55
- // `onExcessProperty: 'error'` rejects unknown keys (the default 'ignore' would
56
- // Silently strip them — a typo'd `blockedComands` would vanish unnoticed, the
57
- // Same silent-policy-drop class this whole change exists to kill). A stray key
58
- // Now fails loud, e.g. the `rtk` block that triggered MTA-137.
59
- const decodeConfig = (unknown: unknown): Effect.Effect<Config, Error> =>
60
- Schema.decodeUnknownEffect(ConfigSchema)(unknown, { onExcessProperty: 'error' });
61
-
62
- const getDefaultConfig = (): Config => ({
63
- git: {
64
- protectedBranches: ['main', 'master', 'develop', 'production', 'release'],
65
- enforceConventionalCommits: true,
66
- },
67
- safePaths: {},
68
- blockedCommands: [],
69
- allowedCommands: [],
70
- });
71
-
72
- const mergeWithDefaults = (partial: Config): Config => ({
73
- git: partial.git ?? getDefaultConfig().git,
74
- safePaths: partial.safePaths ?? getDefaultConfig().safePaths,
75
- blockedCommands: partial.blockedCommands ?? getDefaultConfig().blockedCommands,
76
- allowedCommands: partial.allowedCommands ?? getDefaultConfig().allowedCommands,
77
- });
78
-
79
- // A present-but-broken config (bad JSON, schema decode failure, timeout) must
80
- // Never be papered over with defaults — that silently drops all custom safety
81
- // Policy. `loadConfigResult` reports the failure as data so callers can fail
82
- // Closed loudly (see `loadConfig` and the dispatcher). A *missing* file is the
83
- // One legitimate defaults case.
84
- type ConfigLoad =
85
- | { readonly ok: true; readonly config: Config }
86
- | { readonly ok: false; readonly error: string };
87
-
88
- export const loadConfigResult = (path: string = CONFIG_PATH): Effect.Effect<ConfigLoad> =>
89
- Effect.gen(function* () {
90
- const exists = yield* configExists(path);
91
- if (!exists) {
92
- const result: ConfigLoad = { ok: true, config: getDefaultConfig() };
93
- return result;
94
- }
95
-
96
- const raw = yield* readConfigFile(path);
97
- const parsed = yield* parseConfigJson(raw);
98
- const config = yield* decodeConfig(parsed);
99
- const result: ConfigLoad = { ok: true, config: mergeWithDefaults(config) };
100
- return result;
101
- }).pipe(
102
- Effect.timeout(1000),
103
- Effect.catchCause((cause) => {
104
- const result: ConfigLoad = { ok: false, error: Cause.pretty(cause) };
105
- return Effect.succeed(result);
106
- }),
107
- );
108
-
109
- // Loud loader for library consumers (e.g. the shim daemon) that expect a
110
- // `Config`. A broken config dies rather than silently defaulting, so the
111
- // Consumer fails closed visibly until the file is fixed.
112
- export const loadConfig = (path: string = CONFIG_PATH): Effect.Effect<Config> =>
113
- loadConfigResult(path).pipe(
114
- Effect.flatMap((result) =>
115
- result.ok
116
- ? Effect.succeed(result.config)
117
- : Effect.die(new Error(`[tripwire] config load failed (${path}): ${result.error}`)),
118
- ),
119
- );
120
-
121
- export type BlockRule = typeof BlockRuleSchema.Type;
122
- export type GitConfig = typeof GitConfigSchema.Type;
123
- export type SafePathsConfig = typeof SafePathsConfigSchema.Type;
124
- export type Config = typeof ConfigSchema.Type;
125
-
126
- export type { ConfigLoad };
127
- export { CONFIG_PATH, ConfigSchema, getDefaultConfig, mergeWithDefaults };
@@ -1,36 +0,0 @@
1
- // Decisions are ordered by restrictiveness:
2
- // Allow — let the tool call proceed silently
3
- // Warn — let the tool call proceed but inject a system message so the
4
- // Agent sees the advisory in its next turn
5
- // Ask — Claude Code prompts the user before letting the call proceed
6
- // Deny — block the tool call (PreToolUse) or refuse to surface its
7
- // Output to the model (PostToolUse)
8
- //
9
- type DecisionKind = 'allow' | 'warn' | 'ask' | 'deny';
10
-
11
- interface Decision {
12
- readonly kind: DecisionKind;
13
- readonly rule: string;
14
- readonly message: string;
15
- }
16
-
17
- const order: Record<DecisionKind, number> = { allow: 0, warn: 1, ask: 2, deny: 3 };
18
-
19
- const allow = (rule: string): Decision => ({ kind: 'allow', rule, message: '' });
20
- const warn = (rule: string, message: string): Decision => ({ kind: 'warn', rule, message });
21
- const ask = (rule: string, message: string): Decision => ({ kind: 'ask', rule, message });
22
- const deny = (rule: string, message: string): Decision => ({ kind: 'deny', rule, message });
23
-
24
- // Merge picks the most restrictive kind.
25
- const merge = (decisions: readonly Decision[]): Decision => {
26
- let best: Decision = allow('none');
27
- for (const d of decisions) {
28
- if (order[d.kind] > order[best.kind]) {
29
- best = d;
30
- }
31
- }
32
- return best;
33
- };
34
-
35
- export type { Decision, DecisionKind };
36
- export { allow, ask, deny, merge, warn };
package/src/lib/diff.ts DELETED
@@ -1,26 +0,0 @@
1
- import { readFileSync } from 'node:fs';
2
-
3
- // Lines present in `next` but not in `prev`, compared trimmed.
4
- // Whitespace-only differences are ignored — we want semantic additions.
5
- const addedLines = (prev: string, next: string): string[] => {
6
- const prevSet = new Set(
7
- prev
8
- .split('\n')
9
- .map((l) => l.trim())
10
- .filter((l) => l.length > 0),
11
- );
12
- return next
13
- .split('\n')
14
- .filter((l) => l.trim().length > 0)
15
- .filter((l) => !prevSet.has(l.trim()));
16
- };
17
-
18
- const readFileOrEmpty = (path: string): string => {
19
- try {
20
- return readFileSync(path, 'utf8');
21
- } catch {
22
- return '';
23
- }
24
- };
25
-
26
- export { addedLines, readFileOrEmpty };
package/src/lib/event.ts DELETED
@@ -1,106 +0,0 @@
1
- import { Schema } from 'effect';
2
-
3
- const HookEvent = Schema.Struct({
4
- hook_event_name: Schema.String,
5
- tool_name: Schema.optional(Schema.String),
6
- tool_input: Schema.optional(Schema.Unknown),
7
- tool_response: Schema.optional(Schema.Unknown),
8
- cwd: Schema.optional(Schema.String),
9
- session_id: Schema.optional(Schema.String),
10
- // Codex extension: present on every PreToolUse / PostToolUse event.
11
- turn_id: Schema.optional(Schema.String),
12
- tool_use_id: Schema.optional(Schema.String),
13
- });
14
- type HookEventType = typeof HookEvent.Type;
15
-
16
- interface BashInput {
17
- readonly command: string;
18
- }
19
-
20
- interface EditInput {
21
- readonly file_path: string;
22
- readonly old_string: string;
23
- readonly new_string: string;
24
- }
25
-
26
- interface WriteInput {
27
- readonly file_path: string;
28
- readonly content: string;
29
- }
30
-
31
- interface ReadInput {
32
- readonly file_path: string;
33
- }
34
-
35
- // PostToolUse `tool_response` shape varies by tool. Bash returns
36
- // Stdout/stderr/interrupted; Read returns content; others vary. We extract
37
- // Any string-ish payload we can find for scanning purposes.
38
- interface BashResponse {
39
- readonly stdout?: string;
40
- readonly stderr?: string;
41
- readonly interrupted?: boolean;
42
- }
43
-
44
- interface ReadResponse {
45
- readonly content?: string;
46
- readonly file?: { readonly content?: string };
47
- }
48
-
49
- const isBashInput = (x: unknown): x is BashInput =>
50
- typeof x === 'object' && x !== null && typeof (x as BashInput).command === 'string';
51
-
52
- const isEditInput = (x: unknown): x is EditInput =>
53
- typeof x === 'object' &&
54
- x !== null &&
55
- typeof (x as EditInput).file_path === 'string' &&
56
- typeof (x as EditInput).old_string === 'string' &&
57
- typeof (x as EditInput).new_string === 'string';
58
-
59
- const isWriteInput = (x: unknown): x is WriteInput =>
60
- typeof x === 'object' &&
61
- x !== null &&
62
- typeof (x as WriteInput).file_path === 'string' &&
63
- typeof (x as WriteInput).content === 'string';
64
-
65
- const isReadInput = (x: unknown): x is ReadInput =>
66
- typeof x === 'object' && x !== null && typeof (x as ReadInput).file_path === 'string';
67
-
68
- // Extract any string payload from a tool_response we can scan for secrets.
69
- // Returns concatenated stdout/stderr for Bash, content for Read, or '' if
70
- // Nothing is recognizable.
71
- const extractResponseText = (toolName: string, response: unknown): string => {
72
- if (typeof response !== 'object' || response === null) {
73
- return '';
74
- }
75
- if (toolName === 'Bash') {
76
- const r = response as BashResponse;
77
- return [r.stdout ?? '', r.stderr ?? ''].filter((s) => s.length > 0).join('\n');
78
- }
79
- if (toolName === 'Read') {
80
- const r = response as ReadResponse;
81
- return r.content ?? r.file?.content ?? '';
82
- }
83
- // Best-effort fallback: stringify and let the scanner do its thing.
84
- if (typeof (response as { content?: string }).content === 'string') {
85
- return (response as { content?: string }).content ?? '';
86
- }
87
- return '';
88
- };
89
-
90
- export type {
91
- BashInput,
92
- BashResponse,
93
- EditInput,
94
- HookEventType as HookEvent,
95
- ReadInput,
96
- ReadResponse,
97
- WriteInput,
98
- };
99
- export {
100
- HookEvent as HookEventSchema,
101
- extractResponseText,
102
- isBashInput,
103
- isEditInput,
104
- isReadInput,
105
- isWriteInput,
106
- };
@@ -1,238 +0,0 @@
1
- // Config installation module for tripwire hooks.
2
- // Parses and upserts hook configurations for Claude Code, Codex, and pi-guardrails.
3
-
4
- import { homedir } from 'node:os';
5
-
6
- import { file } from 'bun';
7
-
8
- interface ClaudeConfig {
9
- hooks?: {
10
- PreToolUse?: { hooks: { type: string; command: string }[] }[];
11
- PostToolUse?: { hooks: { type: string; command: string }[] }[];
12
- };
13
- }
14
-
15
- interface PiConfig {
16
- hooks?: {
17
- PreToolUse?: { hooks: { type: string; command: string }[] }[];
18
- PostToolUse?: { hooks: { type: string; command: string }[] }[];
19
- };
20
- }
21
-
22
- interface CodexHooksConfig {
23
- hooks?: {
24
- PreToolUse?: { hooks: { type: string; command: string; timeout?: number }[] }[];
25
- PostToolUse?: { hooks: { type: string; command: string; timeout?: number }[] }[];
26
- };
27
- }
28
-
29
- const TRIPWIRE_HOOK = 'tripwire-hook';
30
-
31
- const addHookIfMissing = (
32
- hooks: { hooks: { type: string; command: string; timeout?: number }[] }[] | undefined,
33
- ): [{ hooks: { type: string; command: string; timeout?: number }[] }[], boolean] => {
34
- if (!hooks) {
35
- const newHooks: { hooks: { type: string; command: string; timeout?: number }[] }[] = [
36
- { hooks: [{ type: 'command', command: TRIPWIRE_HOOK }] },
37
- ];
38
- return [newHooks, false];
39
- }
40
-
41
- let needsNormalization = false;
42
-
43
- const normalizedHooks = hooks.map((h) => ({
44
- hooks: h.hooks.map((hook) => {
45
- if (hook.command === TRIPWIRE_HOOK || hook.command.endsWith('/tripwire-hook')) {
46
- if (hook.command !== TRIPWIRE_HOOK) {
47
- needsNormalization = true;
48
- return { ...hook, command: TRIPWIRE_HOOK };
49
- }
50
- return hook;
51
- }
52
- return hook;
53
- }),
54
- }));
55
-
56
- const hasTripwire = normalizedHooks.some((h) =>
57
- h.hooks.some((hook) => hook.command === TRIPWIRE_HOOK),
58
- );
59
-
60
- if (hasTripwire) {
61
- return [normalizedHooks, !needsNormalization];
62
- }
63
-
64
- const newHooks: { hooks: { type: string; command: string; timeout?: number }[] }[] = [
65
- ...normalizedHooks,
66
- { hooks: [{ type: 'command', command: TRIPWIRE_HOOK }] },
67
- ];
68
- return [newHooks, false];
69
- };
70
-
71
- export const installClaude = async (): Promise<{ success: boolean; message: string }> => {
72
- const configPath = `${homedir()}/.claude/settings.json`;
73
- const configFile = file(configPath);
74
-
75
- try {
76
- const raw = await configFile.text();
77
- const config = JSON.parse(raw) as ClaudeConfig;
78
-
79
- config.hooks ??= {};
80
- const [preToolUse, preSkipped] = addHookIfMissing(config.hooks.PreToolUse);
81
- const [postToolUse, postSkipped] = addHookIfMissing(config.hooks.PostToolUse);
82
-
83
- config.hooks.PreToolUse = preToolUse;
84
- config.hooks.PostToolUse = postToolUse;
85
-
86
- if (preSkipped && postSkipped) {
87
- return { success: true, message: `Already configured: ${configPath}` };
88
- }
89
-
90
- await configFile.write(`${JSON.stringify(config, null, 2)}\n`);
91
-
92
- return { success: true, message: `Updated ${configPath}` };
93
- } catch (error) {
94
- const message = error instanceof Error ? error.message : String(error);
95
- if (message.includes('No such file')) {
96
- return { success: false, message: `Config file not found: ${configPath}` };
97
- }
98
- return { success: false, message: `Failed to update Claude config: ${message}` };
99
- }
100
- };
101
-
102
- export const installPi = async (): Promise<{ success: boolean; message: string }> => {
103
- const configPath = `${homedir()}/.pi/agent/settings.json`;
104
- const configFile = file(configPath);
105
-
106
- try {
107
- const raw = await configFile.text();
108
- const config = JSON.parse(raw) as PiConfig;
109
-
110
- config.hooks ??= {};
111
- const [preToolUse, preSkipped] = addHookIfMissing(config.hooks.PreToolUse);
112
- const [postToolUse, postSkipped] = addHookIfMissing(config.hooks.PostToolUse);
113
-
114
- config.hooks.PreToolUse = preToolUse;
115
- config.hooks.PostToolUse = postToolUse;
116
-
117
- if (preSkipped && postSkipped) {
118
- return { success: true, message: `Already configured: ${configPath}` };
119
- }
120
-
121
- await configFile.write(`${JSON.stringify(config, null, 2)}\n`);
122
-
123
- return { success: true, message: `Updated ${configPath}` };
124
- } catch (error) {
125
- const message = error instanceof Error ? error.message : String(error);
126
- if (message.includes('No such file')) {
127
- return { success: false, message: `Config file not found: ${configPath}` };
128
- }
129
- return { success: false, message: `Failed to update pi config: ${message}` };
130
- }
131
- };
132
-
133
- export const installCodex = async (): Promise<{ success: boolean; message: string }> => {
134
- const configTomlPath = `${homedir()}/.codex/config.toml`;
135
- const hooksJsonPath = `${homedir()}/.codex/hooks.json`;
136
- const hooksJsonFile = file(hooksJsonPath);
137
- const configTomlFile = file(configTomlPath);
138
-
139
- let hooksUpdated = false;
140
- let tomlUpdated = false;
141
-
142
- // First, update hooks.json
143
- try {
144
- const raw = await hooksJsonFile.text();
145
- const config = JSON.parse(raw) as CodexHooksConfig;
146
-
147
- config.hooks ??= {};
148
- const [preToolUse, preSkipped] = addHookIfMissing(config.hooks.PreToolUse);
149
- const [postToolUse, postSkipped] = addHookIfMissing(config.hooks.PostToolUse);
150
-
151
- config.hooks.PreToolUse = preToolUse;
152
- config.hooks.PostToolUse = postToolUse;
153
-
154
- if (!preSkipped || !postSkipped) {
155
- hooksUpdated = true;
156
- }
157
-
158
- // Add timeout to tripwire-hook if not present
159
- const addTimeout = (
160
- hooks: { hooks: { type: string; command: string; timeout?: number }[] }[] | undefined,
161
- ): { hooks: { type: string; command: string; timeout?: number }[] }[] => {
162
- return (
163
- hooks?.map((h) => ({
164
- hooks: h.hooks.map((hook) => {
165
- if (hook.command === TRIPWIRE_HOOK && hook.timeout === undefined) {
166
- return { ...hook, timeout: 10 };
167
- }
168
- return hook;
169
- }),
170
- })) ?? []
171
- );
172
- };
173
-
174
- config.hooks.PreToolUse = addTimeout(config.hooks.PreToolUse);
175
- config.hooks.PostToolUse = addTimeout(config.hooks.PostToolUse);
176
-
177
- if (hooksUpdated) {
178
- await hooksJsonFile.write(`${JSON.stringify(config, null, 2)}\n`);
179
- }
180
- } catch (error) {
181
- const message = error instanceof Error ? error.message : String(error);
182
- if (message.includes('No such file')) {
183
- return { success: false, message: `Config file not found: ${hooksJsonPath}` };
184
- }
185
- return { success: false, message: `Failed to update Codex hooks.json: ${message}` };
186
- }
187
-
188
- // Then, update config.toml to enable hooks
189
- try {
190
- const raw = await configTomlFile.text();
191
- let toml = raw;
192
-
193
- // Enable hooks in [features] section
194
- if (toml.includes('hooks = true')) {
195
- // Already enabled, nothing to do
196
- } else {
197
- tomlUpdated = true;
198
- if (toml.includes('[features]')) {
199
- // Find [features] section and add hooks = true
200
- const featuresIndex = toml.indexOf('[features]');
201
- const nextSectionIndex = toml.indexOf('\n[', featuresIndex + 1);
202
- if (nextSectionIndex === -1) {
203
- toml += '\nhooks = true';
204
- } else {
205
- toml = `${toml.slice(0, nextSectionIndex)}\nhooks = true${toml.slice(nextSectionIndex)}`;
206
- }
207
- } else {
208
- toml += '\n[features]\nhooks = true';
209
- }
210
- }
211
-
212
- if (tomlUpdated) {
213
- await configTomlFile.write(toml);
214
- }
215
- } catch (error) {
216
- const message = error instanceof Error ? error.message : String(error);
217
- if (message.includes('No such file')) {
218
- return { success: false, message: `Config file not found: ${configTomlPath}` };
219
- }
220
- return { success: false, message: `Failed to update Codex config.toml: ${message}` };
221
- }
222
-
223
- if (!hooksUpdated && !tomlUpdated) {
224
- return { success: true, message: `Already configured: ${configTomlPath} and ${hooksJsonPath}` };
225
- }
226
-
227
- return { success: true, message: `Updated ${configTomlPath} and ${hooksJsonPath}` };
228
- };
229
-
230
- export const installAll = async (): Promise<
231
- { target: string; success: boolean; message: string }[]
232
- > => {
233
- return [
234
- { target: 'claude', ...(await installClaude()) },
235
- { target: 'codex', ...(await installCodex()) },
236
- { target: 'pi', ...(await installPi()) },
237
- ];
238
- };
package/src/lib/log.ts DELETED
@@ -1,24 +0,0 @@
1
- import { appendFileSync, mkdirSync } from 'node:fs';
2
- import { homedir } from 'node:os';
3
- // oxlint-disable-next-line unicorn/import-style
4
- import { dirname } from 'node:path';
5
-
6
- const LOG_PATH = `${homedir()}/.claude/tripwire.log`;
7
-
8
- try {
9
- mkdirSync(dirname(LOG_PATH), { recursive: true });
10
- } catch {
11
- // Directory creation failure is non-fatal — logging is best-effort.
12
- }
13
-
14
- const logError = (rule: string, err: unknown): void => {
15
- const stamp = new Date().toISOString();
16
- const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
17
- try {
18
- appendFileSync(LOG_PATH, `[${stamp}] [${rule}] ${msg}\n`);
19
- } catch {
20
- // Never block the agent on a logging failure.
21
- }
22
- };
23
-
24
- export { logError };
@@ -1,121 +0,0 @@
1
- // Secret scanning via the `betterleaks` binary (Zach Rice's gitleaks
2
- // Successor, MIT). We spawn it once per PostToolUse, write the tool
3
- // Output to a temp file, scan the file, parse JSON findings, redact
4
- // Matches in-place, and delete the temp file.
5
- //
6
- // Why subprocess vs. inline regex: betterleaks ships the curated
7
- // 100+-rule pack the gitleaks ecosystem has tuned over years (AWS, GH,
8
- // Stripe, OpenAI, Anthropic, mongo URLs, JWTs, private keys, plus ~70
9
- // Long-tail vendors). We get all of it for one fork+exec, ~250–300ms.
10
- //
11
- // Why a temp file vs. `--pipe`: betterleaks `--pipe` *adds* stdin to its
12
- // Scan but does not replace the directory walk, so it scans the cwd as
13
- // Well. Writing to a tempfile in /tmp and using `--source <file>` is
14
- // Scoped, deterministic, and only ~5ms slower.
15
-
16
- import { spawnSync } from 'node:child_process';
17
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
18
- import { tmpdir } from 'node:os';
19
- // oxlint-disable-next-line unicorn/import-style
20
- import { join } from 'node:path';
21
-
22
- interface BetterleaksFinding {
23
- readonly RuleID: string;
24
- readonly Description: string;
25
- readonly StartLine: number;
26
- readonly EndLine: number;
27
- readonly Secret: string;
28
- readonly Match: string;
29
- }
30
-
31
- interface ScanResult {
32
- readonly hits: readonly { readonly rule: string; readonly count: number }[];
33
- readonly redacted: string;
34
- }
35
-
36
- const BETTERLEAKS_BIN = '/opt/homebrew/bin/betterleaks';
37
-
38
- const summarizeHits = (
39
- findings: readonly BetterleaksFinding[],
40
- ): readonly { rule: string; count: number }[] => {
41
- const counts = new Map<string, number>();
42
- for (const f of findings) {
43
- counts.set(f.RuleID, (counts.get(f.RuleID) ?? 0) + 1);
44
- }
45
- return [...counts.entries()].map(([rule, count]) => ({ rule, count }));
46
- };
47
-
48
- const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
49
-
50
- // Replace every found secret in the original input with a tagged redaction.
51
- const redactWith = (input: string, findings: readonly BetterleaksFinding[]): string => {
52
- let out = input;
53
- // Sort by length descending so shorter matches that are substrings of
54
- // Longer ones don't fire first and break the longer match.
55
- const sorted = [...findings].toSorted((a, b) => b.Secret.length - a.Secret.length);
56
- for (const f of sorted) {
57
- if (f.Secret === '') {
58
- continue;
59
- }
60
- out = out.replaceAll(f.Secret, `[REDACTED:${f.RuleID}]`);
61
- }
62
- return out;
63
- };
64
-
65
- const scanAndRedact = (input: string, timeoutMs = 5000): ScanResult => {
66
- if (input.length === 0) {
67
- return { hits: [], redacted: input };
68
- }
69
- const dir = mkdtempSync(join(tmpdir(), 'tripwire-scan-'));
70
- const inPath = join(dir, 'input');
71
- const reportPath = join(dir, 'report.json');
72
- try {
73
- writeFileSync(inPath, input);
74
- const result = spawnSync(
75
- BETTERLEAKS_BIN,
76
- [
77
- 'detect',
78
- '--no-git',
79
- '--no-banner',
80
- '--no-color',
81
- '--report-format',
82
- 'json',
83
- '--report-path',
84
- reportPath,
85
- '--source',
86
- inPath,
87
- '--exit-code',
88
- '0',
89
- '--log-level',
90
- 'error',
91
- ],
92
- { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 },
93
- );
94
- if (result.error !== undefined) {
95
- return { hits: [], redacted: input };
96
- }
97
- let findings: BetterleaksFinding[];
98
- try {
99
- const raw = readFileSync(reportPath, 'utf8');
100
- const parsed = JSON.parse(raw || '[]') as unknown;
101
- findings = Array.isArray(parsed) ? (parsed as BetterleaksFinding[]) : [];
102
- } catch {
103
- return { hits: [], redacted: input };
104
- }
105
- if (findings.length === 0) {
106
- return { hits: [], redacted: input };
107
- }
108
- return { hits: summarizeHits(findings), redacted: redactWith(input, findings) };
109
- } finally {
110
- try {
111
- rmSync(dir, { recursive: true, force: true });
112
- } catch {
113
- // Best-effort cleanup.
114
- }
115
- }
116
- };
117
-
118
- // `escapeRegExp` is exported so tests / callers can build patterns over the
119
- // Redacted output without re-implementing escaping.
120
- export type { BetterleaksFinding, ScanResult };
121
- export { escapeRegExp, scanAndRedact };