@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.
Files changed (57) hide show
  1. package/README.md +16 -14
  2. package/dist/index.js +35 -0
  3. package/dist/tripwire-cli.js +3 -0
  4. package/dist/tripwire-hook.js +3 -0
  5. package/dist/tripwire-pi.js +6 -4
  6. package/dist/tripwire.js +141 -0
  7. package/dist/types/dispatch.d.ts +18 -0
  8. package/dist/types/index.d.ts +6 -0
  9. package/dist/types/lib/bash.d.ts +27 -0
  10. package/dist/types/lib/config.d.ts +110 -0
  11. package/dist/types/lib/cursor.d.ts +16 -0
  12. package/dist/types/lib/decision.d.ts +13 -0
  13. package/dist/types/lib/diff.d.ts +3 -0
  14. package/dist/types/lib/event.d.ts +45 -0
  15. package/dist/types/lib/log.d.ts +2 -0
  16. package/dist/types/lib/secrets.d.ts +41 -0
  17. package/dist/types/rules/bash-deny.d.ts +5 -0
  18. package/dist/types/rules/bash-git.d.ts +5 -0
  19. package/dist/types/rules/bash-network-install.d.ts +4 -0
  20. package/dist/types/rules/bash-redirect.d.ts +4 -0
  21. package/dist/types/rules/bash-scoped-rm.d.ts +5 -0
  22. package/dist/types/rules/bash-tar-explosion.d.ts +4 -0
  23. package/dist/types/rules/config-custom.d.ts +6 -0
  24. package/dist/types/rules/lazy-code.d.ts +4 -0
  25. package/dist/types/rules/path-protect.d.ts +12 -0
  26. package/dist/types/rules/post-secret-scrub.d.ts +12 -0
  27. package/dist/types/rules/read-protect.d.ts +4 -0
  28. package/dist/types/rules/tool-policy.d.ts +5 -0
  29. package/package.json +16 -13
  30. package/dist/tripwire +0 -0
  31. package/scripts/tripwire-cli +0 -12
  32. package/src/cli.ts +0 -271
  33. package/src/dispatch.ts +0 -562
  34. package/src/index.ts +0 -6
  35. package/src/lib/bash.ts +0 -1328
  36. package/src/lib/config.ts +0 -174
  37. package/src/lib/cursor.ts +0 -336
  38. package/src/lib/decision.ts +0 -36
  39. package/src/lib/diff.ts +0 -29
  40. package/src/lib/event.ts +0 -105
  41. package/src/lib/install.ts +0 -610
  42. package/src/lib/log.ts +0 -23
  43. package/src/lib/secrets.ts +0 -184
  44. package/src/main.ts +0 -31
  45. package/src/pi-extension.ts +0 -337
  46. package/src/rules/bash-deny.ts +0 -404
  47. package/src/rules/bash-git.ts +0 -590
  48. package/src/rules/bash-network-install.ts +0 -75
  49. package/src/rules/bash-redirect.ts +0 -91
  50. package/src/rules/bash-scoped-rm.ts +0 -84
  51. package/src/rules/bash-tar-explosion.ts +0 -77
  52. package/src/rules/config-custom.ts +0 -166
  53. package/src/rules/lazy-code.ts +0 -95
  54. package/src/rules/path-protect.ts +0 -131
  55. package/src/rules/post-secret-scrub.ts +0 -49
  56. package/src/rules/read-protect.ts +0 -57
  57. package/src/rules/tool-policy.ts +0 -54
package/src/lib/config.ts DELETED
@@ -1,174 +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 only if the file does not exist.
4
-
5
- import { readFileSync } from 'node:fs';
6
- import { homedir } from 'node:os';
7
-
8
- import { Cause, Data, 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 ToolPolicyMatchSchema = Schema.Struct({
31
- argumentsIncludeAll: Schema.optional(Schema.Array(Schema.String)),
32
- argumentsStartWith: Schema.optional(Schema.Array(Schema.String)),
33
- shortFlagsIncludeAll: Schema.optional(Schema.Array(Schema.String)),
34
- });
35
-
36
- const ToolPolicySchema = Schema.Struct({
37
- rule: Schema.String,
38
- executables: Schema.Array(Schema.String),
39
- action: Schema.Union([Schema.Literal('deny'), Schema.Literal('warn')]),
40
- message: Schema.String,
41
- match: Schema.optional(ToolPolicyMatchSchema),
42
- });
43
-
44
- const SecretScannerConfigSchema = Schema.Struct({
45
- executable: Schema.String,
46
- timeoutMs: Schema.Finite.check(Schema.isGreaterThan(0)),
47
- });
48
-
49
- const ConfigSchema = Schema.Struct({
50
- git: Schema.optional(GitConfigSchema),
51
- safePaths: Schema.optional(SafePathsConfigSchema),
52
- toolPolicies: Schema.optional(Schema.Array(ToolPolicySchema)),
53
- blockedCommands: Schema.optional(Schema.Array(BlockRuleSchema)),
54
- allowedCommands: Schema.optional(Schema.Array(BlockRuleSchema)),
55
- secretScanner: Schema.optional(SecretScannerConfigSchema),
56
- });
57
-
58
- const CONFIG_PATH = `${homedir()}/.config/tripwire/config.json`;
59
-
60
- class ConfigReadError extends Data.TaggedError('ConfigReadError')<{ readonly cause: unknown }> {}
61
-
62
- class ConfigParseError extends Data.TaggedError('ConfigParseError')<{ readonly cause: unknown }> {}
63
-
64
- const isMissingFile = (cause: unknown): boolean =>
65
- cause instanceof Error && 'code' in cause && cause.code === 'ENOENT';
66
-
67
- const readConfigFile = (path: string): Effect.Effect<string | null, ConfigReadError> =>
68
- Effect.try({
69
- try: () => readFileSync(path, 'utf8'),
70
- catch: (cause) => new ConfigReadError({ cause }),
71
- }).pipe(
72
- Effect.catchTag('ConfigReadError', (error) =>
73
- isMissingFile(error.cause) ? Effect.succeed(null) : Effect.fail(error),
74
- ),
75
- );
76
-
77
- const parseConfigJson = (raw: string): Effect.Effect<unknown, Error> =>
78
- Effect.try({
79
- try: () => JSON.parse(raw) as unknown,
80
- catch: (cause) => new ConfigParseError({ cause }),
81
- });
82
-
83
- // Reject unknown keys so a misspelled policy cannot disappear silently.
84
- const decodeConfig = (unknown: unknown): Effect.Effect<Config, Error> =>
85
- Schema.decodeUnknownEffect(ConfigSchema)(unknown, { onExcessProperty: 'error' });
86
-
87
- const getDefaultConfig = (): ResolvedConfig => ({
88
- git: { protectedBranches: [], enforceConventionalCommits: false },
89
- safePaths: {},
90
- toolPolicies: [],
91
- blockedCommands: [],
92
- allowedCommands: [],
93
- secretScanner: { executable: 'betterleaks', timeoutMs: 5000 },
94
- });
95
-
96
- const mergeWithDefaults = (partial: Config): ResolvedConfig => {
97
- const defaults = getDefaultConfig();
98
- return {
99
- git: {
100
- protectedBranches: partial.git?.protectedBranches ?? defaults.git.protectedBranches,
101
- enforceConventionalCommits:
102
- partial.git?.enforceConventionalCommits ?? defaults.git.enforceConventionalCommits,
103
- },
104
- safePaths: { ...defaults.safePaths, ...partial.safePaths },
105
- toolPolicies: partial.toolPolicies ?? defaults.toolPolicies,
106
- blockedCommands: partial.blockedCommands ?? defaults.blockedCommands,
107
- allowedCommands: partial.allowedCommands ?? defaults.allowedCommands,
108
- secretScanner: partial.secretScanner ?? defaults.secretScanner,
109
- };
110
- };
111
-
112
- // A present but invalid config must not fall back to defaults because that
113
- // would drop custom safety policy. Only a missing file selects defaults.
114
- type ConfigLoad =
115
- | { readonly ok: true; readonly config: ResolvedConfig }
116
- | { readonly ok: false; readonly error: string };
117
-
118
- export const loadConfigResult = (path: string = CONFIG_PATH): Effect.Effect<ConfigLoad> =>
119
- Effect.gen(function* loadConfigResultEffect() {
120
- const raw = yield* readConfigFile(path);
121
- if (raw === null) {
122
- const result: ConfigLoad = { ok: true, config: getDefaultConfig() };
123
- return result;
124
- }
125
-
126
- const parsed = yield* parseConfigJson(raw);
127
- const config = yield* decodeConfig(parsed);
128
- const result: ConfigLoad = { ok: true, config: mergeWithDefaults(config) };
129
- return result;
130
- }).pipe(
131
- Effect.timeout(1000),
132
- Effect.catchCause((cause) => {
133
- const result: ConfigLoad = { ok: false, error: Cause.pretty(cause) };
134
- return Effect.succeed(result);
135
- }),
136
- );
137
-
138
- // Library consumers get a loud failure instead of an unconfigured fallback.
139
- export const loadConfig = (path: string = CONFIG_PATH): Effect.Effect<ResolvedConfig> =>
140
- loadConfigResult(path).pipe(
141
- Effect.flatMap((result) =>
142
- result.ok
143
- ? Effect.succeed(result.config)
144
- : Effect.die(new Error(`[tripwire] config load failed (${path}): ${result.error}`)),
145
- ),
146
- );
147
-
148
- export type BlockRule = typeof BlockRuleSchema.Type;
149
- export type GitConfig = typeof GitConfigSchema.Type;
150
- export type SafePathsConfig = typeof SafePathsConfigSchema.Type;
151
- export type ToolPolicy = typeof ToolPolicySchema.Type;
152
- export type SecretScannerConfig = typeof SecretScannerConfigSchema.Type;
153
- export type Config = typeof ConfigSchema.Type;
154
-
155
- export interface ResolvedConfig {
156
- readonly git: {
157
- readonly protectedBranches: readonly string[];
158
- readonly enforceConventionalCommits: boolean;
159
- };
160
- readonly safePaths: SafePathsConfig;
161
- readonly toolPolicies: readonly ToolPolicy[];
162
- readonly blockedCommands: readonly BlockRule[];
163
- readonly allowedCommands: readonly BlockRule[];
164
- readonly secretScanner: SecretScannerConfig;
165
- }
166
-
167
- export type { ConfigLoad };
168
- export {
169
- CONFIG_PATH,
170
- ConfigSchema,
171
- SecretScannerConfigSchema,
172
- getDefaultConfig,
173
- mergeWithDefaults,
174
- };
package/src/lib/cursor.ts DELETED
@@ -1,336 +0,0 @@
1
- import type { HookEvent } from './event';
2
-
3
- type JsonRecord = Record<string, unknown>;
4
-
5
- type HookHost =
6
- | { readonly kind: 'native' }
7
- | { readonly kind: 'cursor'; readonly eventName: string; readonly post: boolean };
8
-
9
- interface NormalizedHookInput {
10
- readonly event: unknown;
11
- readonly host: HookHost;
12
- }
13
-
14
- const CURSOR_PRE_EVENTS = new Set([
15
- 'preToolUse',
16
- 'beforeShellExecution',
17
- 'beforeMCPExecution',
18
- 'beforeReadFile',
19
- 'beforeTabFileRead',
20
- ]);
21
-
22
- const CURSOR_POST_EVENTS = new Set([
23
- 'postToolUse',
24
- 'postToolUseFailure',
25
- 'afterShellExecution',
26
- 'afterMCPExecution',
27
- 'afterFileEdit',
28
- 'afterTabFileEdit',
29
- ]);
30
-
31
- const cursorHost = (eventName: string): HookHost => ({
32
- kind: 'cursor',
33
- eventName,
34
- post: CURSOR_POST_EVENTS.has(eventName),
35
- });
36
-
37
- const isCursorEventName = (eventName: string): boolean =>
38
- CURSOR_PRE_EVENTS.has(eventName) || CURSOR_POST_EVENTS.has(eventName);
39
-
40
- const isRecord = (value: unknown): value is JsonRecord =>
41
- typeof value === 'object' && value !== null && !Array.isArray(value);
42
-
43
- const stringField = (value: JsonRecord, ...names: readonly string[]): string | undefined => {
44
- for (const name of names) {
45
- const candidate = value[name];
46
- if (typeof candidate === 'string') {
47
- return candidate;
48
- }
49
- }
50
- return undefined;
51
- };
52
-
53
- const valueField = (value: JsonRecord, ...names: readonly string[]): unknown => {
54
- for (const name of names) {
55
- if (name in value) {
56
- return value[name];
57
- }
58
- }
59
- return undefined;
60
- };
61
-
62
- const inputStringField = (
63
- raw: JsonRecord,
64
- input: unknown,
65
- ...names: readonly string[]
66
- ): string | undefined => {
67
- const fromRaw = stringField(raw, ...names);
68
- if (fromRaw !== undefined) {
69
- return fromRaw;
70
- }
71
- if (typeof input === 'string' && names.includes('command')) {
72
- return input;
73
- }
74
- return isRecord(input) ? stringField(input, ...names) : undefined;
75
- };
76
-
77
- const requiredString = (value: string | undefined, label: string): string => {
78
- if (value === undefined) {
79
- throw new Error(`Cursor hook payload missing ${label}`);
80
- }
81
- return value;
82
- };
83
-
84
- const normalizeCursorToolName = (name: string): string => {
85
- const normalized = name.toLowerCase();
86
- if (
87
- normalized === 'bash' ||
88
- normalized === 'exec' ||
89
- normalized === 'shell' ||
90
- normalized === 'run_command'
91
- ) {
92
- return 'Bash';
93
- }
94
- if (normalized === 'read' || normalized === 'read_file') {
95
- return 'Read';
96
- }
97
- if (normalized === 'write' || normalized === 'write_file') {
98
- return 'Write';
99
- }
100
- if (
101
- normalized === 'edit' ||
102
- normalized === 'edit_file' ||
103
- normalized === 'multiedit' ||
104
- normalized === 'apply_patch'
105
- ) {
106
- return 'Edit';
107
- }
108
- return name;
109
- };
110
-
111
- const isFileTool = (tool: string): boolean =>
112
- tool === 'Read' || tool === 'Write' || tool === 'Edit';
113
-
114
- const normalizeBashInput = (input: unknown, required: boolean) => {
115
- let command: string | undefined;
116
- if (typeof input === 'string') {
117
- command = input;
118
- } else if (isRecord(input)) {
119
- command = stringField(input, 'command', 'cmd');
120
- }
121
- if (required && command === undefined) {
122
- throw new Error('Cursor hook payload missing command');
123
- }
124
- if (typeof input === 'string') {
125
- return { command: input };
126
- }
127
- if (!isRecord(input) || command === undefined) {
128
- return input;
129
- }
130
- return { ...input, command };
131
- };
132
-
133
- const normalizeFileInput = (tool: string, input: JsonRecord, required: boolean): JsonRecord => {
134
- const filePath = stringField(input, 'file_path', 'filePath', 'path');
135
- if (required && filePath === undefined) {
136
- throw new Error(`Cursor ${tool} hook payload missing file path`);
137
- }
138
- if (tool === 'Read') {
139
- return { ...input, file_path: filePath ?? '' };
140
- }
141
- if (tool === 'Write') {
142
- const content = stringField(input, 'content', 'fileText', 'text');
143
- if (required && content === undefined) {
144
- throw new Error('Cursor Write hook payload missing content');
145
- }
146
- return { ...input, file_path: filePath ?? '', content: content ?? '' };
147
- }
148
- if (tool === 'Edit') {
149
- const oldString = stringField(input, 'old_string', 'oldString');
150
- const newString = stringField(input, 'new_string', 'newString');
151
- if (required && (oldString === undefined || newString === undefined)) {
152
- throw new Error('Cursor Edit hook payload missing oldString or newString');
153
- }
154
- return {
155
- ...input,
156
- file_path: filePath ?? '',
157
- old_string: oldString ?? '',
158
- new_string: newString ?? '',
159
- };
160
- }
161
- return input;
162
- };
163
-
164
- const normalizeToolInput = (tool: string, input: unknown, required = true): unknown => {
165
- if (tool === 'Bash') {
166
- return normalizeBashInput(input, required);
167
- }
168
- if (!isFileTool(tool)) {
169
- return input;
170
- }
171
- if (!isRecord(input)) {
172
- if (required) {
173
- throw new Error(`Cursor ${tool} hook payload is missing tool input`);
174
- }
175
- return input;
176
- }
177
- return normalizeFileInput(tool, input, required);
178
- };
179
-
180
- const normalizeToolResponse = (tool: string, response: unknown) => {
181
- if (typeof response !== 'string') {
182
- return response;
183
- }
184
- if (tool === 'Bash') {
185
- return { stdout: response, stderr: '' };
186
- }
187
- if (tool === 'Read') {
188
- return { content: response };
189
- }
190
- return { content: response };
191
- };
192
-
193
- const cursorToolName = (eventName: string, raw: JsonRecord): string => {
194
- if (eventName === 'beforeShellExecution' || eventName === 'afterShellExecution') {
195
- return 'Bash';
196
- }
197
- if (
198
- eventName === 'beforeReadFile' ||
199
- eventName === 'beforeTabFileRead' ||
200
- eventName === 'afterFileEdit' ||
201
- eventName === 'afterTabFileEdit'
202
- ) {
203
- return eventName.startsWith('before') ? 'Read' : 'Edit';
204
- }
205
- const rawName = stringField(raw, 'tool_name', 'toolName');
206
- if (rawName !== undefined) {
207
- return normalizeCursorToolName(rawName);
208
- }
209
- const input = valueField(raw, 'tool_input', 'toolInput');
210
- if (
211
- stringField(raw, 'command', 'cmd') !== undefined ||
212
- typeof input === 'string' ||
213
- (isRecord(input) && stringField(input, 'command', 'cmd') !== undefined)
214
- ) {
215
- return 'Bash';
216
- }
217
- if (
218
- stringField(raw, 'file_path', 'filePath', 'path') !== undefined ||
219
- (isRecord(input) && stringField(input, 'file_path', 'filePath', 'path') !== undefined)
220
- ) {
221
- return 'Read';
222
- }
223
- return '';
224
- };
225
-
226
- const cursorTopLevelToolInput = (tool: string, raw: JsonRecord): JsonRecord => {
227
- if (tool === 'Bash') {
228
- return { command: inputStringField(raw, undefined, 'command', 'cmd') };
229
- }
230
- const input: JsonRecord = { file_path: stringField(raw, 'file_path', 'filePath', 'path') };
231
- if (tool === 'Write') {
232
- input['content'] = stringField(raw, 'content', 'fileText', 'text');
233
- }
234
- if (tool === 'Edit') {
235
- input['old_string'] = stringField(raw, 'old_string', 'oldString');
236
- input['new_string'] = stringField(raw, 'new_string', 'newString');
237
- }
238
- return input;
239
- };
240
-
241
- const cursorToolInput = (eventName: string, tool: string, raw: JsonRecord, post: boolean) => {
242
- const input = valueField(raw, 'tool_input', 'toolInput');
243
- const required = !post;
244
- if (eventName === 'beforeShellExecution' || eventName === 'afterShellExecution') {
245
- const command = inputStringField(raw, input, 'command', 'cmd');
246
- return { command: required ? requiredString(command, 'command') : (command ?? '') };
247
- }
248
- if (
249
- eventName === 'beforeReadFile' ||
250
- eventName === 'beforeTabFileRead' ||
251
- eventName === 'afterFileEdit' ||
252
- eventName === 'afterTabFileEdit'
253
- ) {
254
- const filePath = inputStringField(raw, input, 'file_path', 'filePath', 'path');
255
- return normalizeToolInput(
256
- tool,
257
- {
258
- file_path: required ? requiredString(filePath, 'file path') : (filePath ?? ''),
259
- ...(Array.isArray(raw['edits']) && { edits: raw['edits'] }),
260
- },
261
- required,
262
- );
263
- }
264
- if (input === undefined && ['Bash', 'Read', 'Write', 'Edit'].includes(tool)) {
265
- return normalizeToolInput(tool, cursorTopLevelToolInput(tool, raw), required);
266
- }
267
- return normalizeToolInput(tool, input, required);
268
- };
269
-
270
- const cursorToolResponse = (eventName: string, tool: string, raw: JsonRecord): unknown => {
271
- if (eventName === 'afterShellExecution') {
272
- return normalizeToolResponse(tool, stringField(raw, 'output') ?? '');
273
- }
274
- const response = valueField(
275
- raw,
276
- 'tool_response',
277
- 'toolResponse',
278
- 'tool_output',
279
- 'toolOutput',
280
- 'result',
281
- 'result_json',
282
- );
283
- return normalizeToolResponse(tool, response);
284
- };
285
-
286
- const normalizeCursorEvent = (
287
- raw: JsonRecord,
288
- eventName: string,
289
- post: boolean,
290
- ): { readonly event: HookEvent; readonly host: HookHost } => {
291
- const tool = cursorToolName(eventName, raw);
292
- const input = cursorToolInput(eventName, tool, raw, post);
293
- const response = post ? cursorToolResponse(eventName, tool, raw) : undefined;
294
- const cwd = stringField(raw, 'cwd');
295
- const sessionId = stringField(raw, 'conversation_id', 'session_id');
296
- const toolUseId = stringField(raw, 'tool_use_id');
297
- const event: HookEvent = {
298
- hook_event_name: post ? 'PostToolUse' : 'PreToolUse',
299
- ...(tool.length > 0 && { tool_name: tool }),
300
- ...(input !== undefined && { tool_input: input }),
301
- ...(response !== undefined && { tool_response: response }),
302
- ...(cwd !== undefined && { cwd }),
303
- ...(sessionId !== undefined && { session_id: sessionId }),
304
- ...(toolUseId !== undefined && { tool_use_id: toolUseId }),
305
- };
306
- return { event, host: cursorHost(eventName) };
307
- };
308
-
309
- const normalizeHookInput = (raw: unknown, hintedEventName?: string): NormalizedHookInput => {
310
- if (hintedEventName !== undefined && !isCursorEventName(hintedEventName)) {
311
- throw new Error(`Unknown Cursor hook event "${hintedEventName}"`);
312
- }
313
- if (!isRecord(raw)) {
314
- if (hintedEventName !== undefined) {
315
- throw new Error(`Cursor ${hintedEventName} hook payload must be a JSON object`);
316
- }
317
- return { event: raw, host: { kind: 'native' } };
318
- }
319
- const eventName = hintedEventName ?? stringField(raw, 'hook_event_name');
320
- if (eventName !== undefined && CURSOR_PRE_EVENTS.has(eventName)) {
321
- return normalizeCursorEvent(raw, eventName, false);
322
- }
323
- if (eventName !== undefined && CURSOR_POST_EVENTS.has(eventName)) {
324
- return normalizeCursorEvent(raw, eventName, true);
325
- }
326
- return { event: raw, host: { kind: 'native' } };
327
- };
328
-
329
- export {
330
- CURSOR_POST_EVENTS,
331
- CURSOR_PRE_EVENTS,
332
- cursorHost,
333
- normalizeHookInput,
334
- type HookHost,
335
- type NormalizedHookInput,
336
- };
@@ -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,29 +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 (error) {
22
- if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
23
- return '';
24
- }
25
- throw error;
26
- }
27
- };
28
-
29
- export { addedLines, readFileOrEmpty };
package/src/lib/event.ts DELETED
@@ -1,105 +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 isRecord = (value: unknown): value is Record<string, unknown> =>
50
- typeof value === 'object' && value !== null && !Array.isArray(value);
51
-
52
- const isBashInput = (x: unknown): x is BashInput => isRecord(x) && typeof x['command'] === 'string';
53
-
54
- const isEditInput = (x: unknown): x is EditInput =>
55
- isRecord(x) &&
56
- typeof x['file_path'] === 'string' &&
57
- typeof x['old_string'] === 'string' &&
58
- typeof x['new_string'] === 'string';
59
-
60
- const isWriteInput = (x: unknown): x is WriteInput =>
61
- isRecord(x) && typeof x['file_path'] === 'string' && typeof x['content'] === 'string';
62
-
63
- const isReadInput = (x: unknown): x is ReadInput =>
64
- isRecord(x) && typeof x['file_path'] === 'string';
65
-
66
- // Extract any string payload from a tool_response we can scan for secrets.
67
- // Returns concatenated stdout/stderr for Bash, content for Read, or '' if
68
- // Nothing is recognizable.
69
- const extractResponseText = (toolName: string, response: unknown): string => {
70
- if (!isRecord(response)) {
71
- return '';
72
- }
73
- if (toolName === 'Bash') {
74
- const stdout = typeof response['stdout'] === 'string' ? response['stdout'] : '';
75
- const stderr = typeof response['stderr'] === 'string' ? response['stderr'] : '';
76
- return [stdout, stderr].filter((text) => text.length > 0).join('\n');
77
- }
78
- if (toolName === 'Read') {
79
- if (typeof response['content'] === 'string') {
80
- return response['content'];
81
- }
82
- const { file } = response;
83
- return isRecord(file) && typeof file['content'] === 'string' ? file['content'] : '';
84
- }
85
- // Best-effort fallback: stringify and let the scanner do its thing.
86
- return typeof response['content'] === 'string' ? response['content'] : '';
87
- };
88
-
89
- export type {
90
- BashInput,
91
- BashResponse,
92
- EditInput,
93
- HookEventType as HookEvent,
94
- ReadInput,
95
- ReadResponse,
96
- WriteInput,
97
- };
98
- export {
99
- HookEvent as HookEventSchema,
100
- extractResponseText,
101
- isBashInput,
102
- isEditInput,
103
- isReadInput,
104
- isWriteInput,
105
- };