@automagik/genie 0.260202.530 → 0.260202.1833
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/dist/claudio.js +44 -45
- package/dist/genie.js +58 -135
- package/dist/term.js +134 -67
- package/install.sh +43 -7
- package/package.json +1 -1
- package/src/claudio.ts +31 -21
- package/src/commands/launch.ts +12 -68
- package/src/genie-commands/doctor.ts +327 -0
- package/src/genie-commands/setup.ts +317 -199
- package/src/genie-commands/uninstall.ts +176 -0
- package/src/genie.ts +24 -44
- package/src/lib/claude-settings.ts +22 -64
- package/src/lib/genie-config.ts +169 -57
- package/src/lib/orchestrator/completion.ts +392 -0
- package/src/lib/orchestrator/event-monitor.ts +442 -0
- package/src/lib/orchestrator/index.ts +12 -0
- package/src/lib/orchestrator/patterns.ts +277 -0
- package/src/lib/orchestrator/state-detector.ts +339 -0
- package/src/lib/version.ts +1 -1
- package/src/lib/worker-registry.ts +229 -0
- package/src/term-commands/close.ts +221 -0
- package/src/term-commands/exec.ts +28 -6
- package/src/term-commands/kill.ts +143 -0
- package/src/term-commands/orchestrate.ts +844 -0
- package/src/term-commands/read.ts +6 -1
- package/src/term-commands/shortcuts.ts +14 -14
- package/src/term-commands/work.ts +415 -0
- package/src/term-commands/workers.ts +264 -0
- package/src/term.ts +201 -3
- package/src/types/genie-config.ts +49 -81
- package/src/genie-commands/hooks.ts +0 -317
- package/src/lib/hook-script.ts +0 -263
- package/src/lib/hooks/compose.ts +0 -72
- package/src/lib/hooks/index.ts +0 -163
- package/src/lib/hooks/presets/audited.ts +0 -191
- package/src/lib/hooks/presets/collaborative.ts +0 -143
- package/src/lib/hooks/presets/sandboxed.ts +0 -153
- package/src/lib/hooks/presets/supervised.ts +0 -66
- package/src/lib/hooks/utils/escape.ts +0 -46
package/src/lib/hooks/index.ts
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Hook Preset Library
|
|
3
|
-
*
|
|
4
|
-
* Provides reusable hook configurations that can be composed
|
|
5
|
-
* to enforce agent behaviors architecturally (without prompting).
|
|
6
|
-
*
|
|
7
|
-
* Usage:
|
|
8
|
-
* import { loadHooksFromConfig, presets, composeHooks } from './hooks';
|
|
9
|
-
*
|
|
10
|
-
* // Load hooks based on genie config
|
|
11
|
-
* const hooks = await loadHooksFromConfig();
|
|
12
|
-
*
|
|
13
|
-
* // Or compose manually
|
|
14
|
-
* const hooks = composeHooks(
|
|
15
|
-
* presets.collaborative(),
|
|
16
|
-
* presets.audited({ logPath: '~/.genie/audit.log' })
|
|
17
|
-
* );
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { loadGenieConfig } from '../genie-config.js';
|
|
21
|
-
import type {
|
|
22
|
-
GenieConfig,
|
|
23
|
-
PresetName,
|
|
24
|
-
CollaborativeConfig,
|
|
25
|
-
SupervisedConfig,
|
|
26
|
-
SandboxedConfig,
|
|
27
|
-
AuditedConfig,
|
|
28
|
-
} from '../../types/genie-config.js';
|
|
29
|
-
|
|
30
|
-
// Re-export types
|
|
31
|
-
export type { HookConfig, HookCallback, HookInput, HookOutput } from './presets/collaborative.js';
|
|
32
|
-
|
|
33
|
-
// Re-export utilities
|
|
34
|
-
export { mergeHooks, composeHooks, isEmptyHookConfig, countHooks } from './compose.js';
|
|
35
|
-
export { escapeForSingleQuotes, escapeForDoubleQuotes, singleQuote, doubleQuote } from './utils/escape.js';
|
|
36
|
-
|
|
37
|
-
// Import presets
|
|
38
|
-
import { collaborativeHooks, type CollaborativeHookConfig } from './presets/collaborative.js';
|
|
39
|
-
import { supervisedHooks, type SupervisedHookConfig } from './presets/supervised.js';
|
|
40
|
-
import { sandboxedHooks, type SandboxedHookConfig } from './presets/sandboxed.js';
|
|
41
|
-
import { auditedHooks, type AuditedHookConfig } from './presets/audited.js';
|
|
42
|
-
|
|
43
|
-
// Re-export preset types
|
|
44
|
-
export type { CollaborativeHookConfig } from './presets/collaborative.js';
|
|
45
|
-
export type { SupervisedHookConfig } from './presets/supervised.js';
|
|
46
|
-
export type { SandboxedHookConfig } from './presets/sandboxed.js';
|
|
47
|
-
export type { AuditedHookConfig } from './presets/audited.js';
|
|
48
|
-
|
|
49
|
-
// Import compose utilities
|
|
50
|
-
import { composeHooks } from './compose.js';
|
|
51
|
-
import type { HookConfig } from './presets/collaborative.js';
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* All available presets as functions
|
|
55
|
-
*/
|
|
56
|
-
export const presets = {
|
|
57
|
-
collaborative: collaborativeHooks,
|
|
58
|
-
supervised: supervisedHooks,
|
|
59
|
-
sandboxed: sandboxedHooks,
|
|
60
|
-
audited: auditedHooks,
|
|
61
|
-
} as const;
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Preset factory type
|
|
65
|
-
*/
|
|
66
|
-
export type PresetFactory = typeof presets[PresetName];
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Load hooks from genie config file
|
|
70
|
-
*
|
|
71
|
-
* Reads ~/.genie/config.json and builds a combined hook configuration
|
|
72
|
-
* from all enabled presets.
|
|
73
|
-
*
|
|
74
|
-
* @returns Combined hook configuration from all enabled presets
|
|
75
|
-
*/
|
|
76
|
-
export async function loadHooksFromConfig(): Promise<HookConfig> {
|
|
77
|
-
const config = await loadGenieConfig();
|
|
78
|
-
return buildHooksFromConfig(config);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Build hooks from a genie config object
|
|
83
|
-
*
|
|
84
|
-
* This is useful for testing or when you already have the config loaded.
|
|
85
|
-
*/
|
|
86
|
-
export function buildHooksFromConfig(config: GenieConfig): HookConfig {
|
|
87
|
-
const enabledPresets = config.hooks.enabled;
|
|
88
|
-
|
|
89
|
-
if (enabledPresets.length === 0) {
|
|
90
|
-
return {};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const hookConfigs: HookConfig[] = [];
|
|
94
|
-
|
|
95
|
-
for (const presetName of enabledPresets) {
|
|
96
|
-
const presetConfig = config.hooks[presetName] || {};
|
|
97
|
-
const presetFactory = presets[presetName];
|
|
98
|
-
|
|
99
|
-
if (presetFactory) {
|
|
100
|
-
hookConfigs.push(presetFactory(presetConfig as any));
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return composeHooks(...hookConfigs);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Get a human-readable description of the enabled hooks
|
|
109
|
-
*/
|
|
110
|
-
export function describeEnabledHooks(config: GenieConfig): string[] {
|
|
111
|
-
const descriptions: string[] = [];
|
|
112
|
-
|
|
113
|
-
for (const presetName of config.hooks.enabled) {
|
|
114
|
-
switch (presetName) {
|
|
115
|
-
case 'collaborative': {
|
|
116
|
-
const collabConfig: Partial<CollaborativeConfig> = config.hooks.collaborative || {};
|
|
117
|
-
const session = collabConfig.sessionName || 'genie';
|
|
118
|
-
const window = collabConfig.windowName || 'shell';
|
|
119
|
-
descriptions.push(`Collaborative: Bash → term exec ${session}:${window}`);
|
|
120
|
-
break;
|
|
121
|
-
}
|
|
122
|
-
case 'supervised': {
|
|
123
|
-
const supervisedConfig: Partial<SupervisedConfig> = config.hooks.supervised || {};
|
|
124
|
-
const tools = supervisedConfig.alwaysAsk || ['Write', 'Edit'];
|
|
125
|
-
descriptions.push(`Supervised: ${tools.join(', ')} require approval`);
|
|
126
|
-
break;
|
|
127
|
-
}
|
|
128
|
-
case 'sandboxed': {
|
|
129
|
-
const sandboxConfig: Partial<SandboxedConfig> = config.hooks.sandboxed || {};
|
|
130
|
-
const paths = sandboxConfig.allowedPaths || ['~/projects', '/tmp'];
|
|
131
|
-
descriptions.push(`Sandboxed: Restricted to ${paths.join(', ')}`);
|
|
132
|
-
break;
|
|
133
|
-
}
|
|
134
|
-
case 'audited': {
|
|
135
|
-
const auditConfig: Partial<AuditedConfig> = config.hooks.audited || {};
|
|
136
|
-
const logPath = auditConfig.logPath || '~/.genie/audit.log';
|
|
137
|
-
descriptions.push(`Audited: Logging to ${logPath}`);
|
|
138
|
-
break;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
return descriptions;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Check if any hooks are enabled
|
|
148
|
-
*/
|
|
149
|
-
export function hasEnabledHooks(config: GenieConfig): boolean {
|
|
150
|
-
return config.hooks.enabled.length > 0;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Parse hook names from a comma-separated string
|
|
155
|
-
*/
|
|
156
|
-
export function parseHookNames(input: string): PresetName[] {
|
|
157
|
-
const validPresets: PresetName[] = ['collaborative', 'supervised', 'sandboxed', 'audited'];
|
|
158
|
-
const names = input.split(',').map((s) => s.trim().toLowerCase());
|
|
159
|
-
|
|
160
|
-
return names.filter((name): name is PresetName =>
|
|
161
|
-
validPresets.includes(name as PresetName)
|
|
162
|
-
);
|
|
163
|
-
}
|
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Audited Hook Preset
|
|
3
|
-
*
|
|
4
|
-
* Logs all tool executions to a file for later review.
|
|
5
|
-
* Uses PostToolUse hooks to capture tool inputs and outputs.
|
|
6
|
-
*
|
|
7
|
-
* Usage:
|
|
8
|
-
* const hooks = auditedHooks({ logPath: '~/.genie/audit.log' });
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { appendFileSync, mkdirSync, existsSync } from 'fs';
|
|
12
|
-
import { dirname, resolve } from 'path';
|
|
13
|
-
import { homedir } from 'os';
|
|
14
|
-
import type { AuditedConfig } from '../../../types/genie-config.js';
|
|
15
|
-
import type { HookConfig, HookInput, HookOutput } from './collaborative.js';
|
|
16
|
-
|
|
17
|
-
export interface AuditedHookConfig {
|
|
18
|
-
logPath: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Default configuration for audited hooks
|
|
23
|
-
*/
|
|
24
|
-
export const DEFAULT_AUDITED_CONFIG: AuditedHookConfig = {
|
|
25
|
-
logPath: '~/.genie/audit.log',
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Expand ~ to home directory
|
|
30
|
-
*/
|
|
31
|
-
function expandTilde(path: string): string {
|
|
32
|
-
if (path.startsWith('~/')) {
|
|
33
|
-
return homedir() + path.slice(1);
|
|
34
|
-
}
|
|
35
|
-
if (path === '~') {
|
|
36
|
-
return homedir();
|
|
37
|
-
}
|
|
38
|
-
return path;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Format a tool input/output for logging
|
|
43
|
-
* Truncates very long values to avoid log bloat
|
|
44
|
-
*/
|
|
45
|
-
function formatForLog(value: unknown, maxLength: number = 1000): string {
|
|
46
|
-
try {
|
|
47
|
-
const str = JSON.stringify(value);
|
|
48
|
-
if (str.length > maxLength) {
|
|
49
|
-
return str.slice(0, maxLength) + '...[truncated]';
|
|
50
|
-
}
|
|
51
|
-
return str;
|
|
52
|
-
} catch {
|
|
53
|
-
return String(value);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Audit log entry
|
|
59
|
-
*/
|
|
60
|
-
interface AuditEntry {
|
|
61
|
-
timestamp: string;
|
|
62
|
-
tool: string;
|
|
63
|
-
input: unknown;
|
|
64
|
-
response?: unknown;
|
|
65
|
-
duration_ms?: number;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Append an entry to the audit log
|
|
70
|
-
*/
|
|
71
|
-
function appendToLog(logPath: string, entry: AuditEntry): void {
|
|
72
|
-
const expandedPath = resolve(expandTilde(logPath));
|
|
73
|
-
const dir = dirname(expandedPath);
|
|
74
|
-
|
|
75
|
-
// Ensure directory exists
|
|
76
|
-
if (!existsSync(dir)) {
|
|
77
|
-
mkdirSync(dir, { recursive: true });
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const logLine = JSON.stringify({
|
|
81
|
-
...entry,
|
|
82
|
-
input: formatForLog(entry.input),
|
|
83
|
-
response: entry.response ? formatForLog(entry.response) : undefined,
|
|
84
|
-
}) + '\n';
|
|
85
|
-
|
|
86
|
-
appendFileSync(expandedPath, logLine, 'utf-8');
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Track tool start times for duration calculation
|
|
90
|
-
const toolStartTimes = new Map<string, number>();
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Create audited hooks configuration
|
|
94
|
-
*
|
|
95
|
-
* This hook captures all tool executions using PostToolUse hooks and
|
|
96
|
-
* logs them to a file in JSONL format for later review.
|
|
97
|
-
*/
|
|
98
|
-
export function auditedHooks(config: Partial<AuditedConfig> = {}): HookConfig {
|
|
99
|
-
const logPath = config.logPath || DEFAULT_AUDITED_CONFIG.logPath;
|
|
100
|
-
|
|
101
|
-
return {
|
|
102
|
-
PreToolUse: [
|
|
103
|
-
{
|
|
104
|
-
// Match all tools
|
|
105
|
-
matcher: /.*/,
|
|
106
|
-
timeout: 10,
|
|
107
|
-
hooks: [
|
|
108
|
-
async (input: HookInput, toolUseID, _options): Promise<HookOutput> => {
|
|
109
|
-
// Record start time for duration calculation
|
|
110
|
-
if (toolUseID) {
|
|
111
|
-
toolStartTimes.set(toolUseID, Date.now());
|
|
112
|
-
}
|
|
113
|
-
return { continue: true };
|
|
114
|
-
},
|
|
115
|
-
],
|
|
116
|
-
},
|
|
117
|
-
],
|
|
118
|
-
PostToolUse: [
|
|
119
|
-
{
|
|
120
|
-
// Match all tools
|
|
121
|
-
matcher: /.*/,
|
|
122
|
-
timeout: 10,
|
|
123
|
-
hooks: [
|
|
124
|
-
async (input: HookInput, toolUseID, _options): Promise<HookOutput> => {
|
|
125
|
-
// Type guard - only process PostToolUse events
|
|
126
|
-
if (input.hook_event_name !== 'PostToolUse') {
|
|
127
|
-
return { continue: true };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Calculate duration if we have a start time
|
|
131
|
-
let duration_ms: number | undefined;
|
|
132
|
-
if (toolUseID && toolStartTimes.has(toolUseID)) {
|
|
133
|
-
duration_ms = Date.now() - toolStartTimes.get(toolUseID)!;
|
|
134
|
-
toolStartTimes.delete(toolUseID);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const entry: AuditEntry = {
|
|
138
|
-
timestamp: new Date().toISOString(),
|
|
139
|
-
tool: input.tool_name || 'unknown',
|
|
140
|
-
input: input.tool_input,
|
|
141
|
-
response: input.tool_response,
|
|
142
|
-
duration_ms,
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
appendToLog(logPath, entry);
|
|
147
|
-
} catch (error) {
|
|
148
|
-
// Don't fail the tool call if logging fails
|
|
149
|
-
console.warn(`Audit log warning: ${error}`);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
return { continue: true };
|
|
153
|
-
},
|
|
154
|
-
],
|
|
155
|
-
},
|
|
156
|
-
],
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Read the audit log as an array of entries
|
|
162
|
-
* Useful for analysis and review
|
|
163
|
-
*/
|
|
164
|
-
export function readAuditLog(logPath: string = DEFAULT_AUDITED_CONFIG.logPath): AuditEntry[] {
|
|
165
|
-
const expandedPath = resolve(expandTilde(logPath));
|
|
166
|
-
|
|
167
|
-
if (!existsSync(expandedPath)) {
|
|
168
|
-
return [];
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const { readFileSync } = require('fs');
|
|
172
|
-
const content = readFileSync(expandedPath, 'utf-8');
|
|
173
|
-
const lines = content.trim().split('\n').filter(Boolean);
|
|
174
|
-
|
|
175
|
-
return lines.map((line: string) => {
|
|
176
|
-
try {
|
|
177
|
-
return JSON.parse(line);
|
|
178
|
-
} catch {
|
|
179
|
-
return { error: 'parse_failed', raw: line };
|
|
180
|
-
}
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Clear the audit log
|
|
186
|
-
*/
|
|
187
|
-
export function clearAuditLog(logPath: string = DEFAULT_AUDITED_CONFIG.logPath): void {
|
|
188
|
-
const expandedPath = resolve(expandTilde(logPath));
|
|
189
|
-
const { writeFileSync } = require('fs');
|
|
190
|
-
writeFileSync(expandedPath, '', 'utf-8');
|
|
191
|
-
}
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Collaborative Hook Preset
|
|
3
|
-
*
|
|
4
|
-
* Makes all terminal operations human-observable via tmux.
|
|
5
|
-
* Intercepts Bash tool calls and rewrites them to go through `term exec`.
|
|
6
|
-
*
|
|
7
|
-
* Usage:
|
|
8
|
-
* const hooks = collaborativeHooks({ sessionName: 'genie', windowName: 'shell' });
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import type { CollaborativeConfig } from '../../../types/genie-config.js';
|
|
12
|
-
import { escapeForSingleQuotes } from '../utils/escape.js';
|
|
13
|
-
|
|
14
|
-
export interface CollaborativeHookConfig {
|
|
15
|
-
sessionName: string;
|
|
16
|
-
windowName: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Default configuration for collaborative hooks
|
|
21
|
-
*/
|
|
22
|
-
export const DEFAULT_COLLABORATIVE_CONFIG: CollaborativeHookConfig = {
|
|
23
|
-
sessionName: 'genie',
|
|
24
|
-
windowName: 'shell',
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Create collaborative hooks configuration
|
|
29
|
-
*
|
|
30
|
-
* This hook intercepts all Bash tool calls and rewrites them to execute
|
|
31
|
-
* through `term exec <session>:<window> '<command>'`, making all terminal
|
|
32
|
-
* operations visible in a tmux session that humans can attach to.
|
|
33
|
-
*/
|
|
34
|
-
export function collaborativeHooks(config: Partial<CollaborativeConfig> = {}): HookConfig {
|
|
35
|
-
const sessionName = config.sessionName || DEFAULT_COLLABORATIVE_CONFIG.sessionName;
|
|
36
|
-
const windowName = config.windowName || DEFAULT_COLLABORATIVE_CONFIG.windowName;
|
|
37
|
-
const target = `${sessionName}:${windowName}`;
|
|
38
|
-
|
|
39
|
-
return {
|
|
40
|
-
PreToolUse: [
|
|
41
|
-
{
|
|
42
|
-
matcher: 'Bash',
|
|
43
|
-
timeout: 30,
|
|
44
|
-
hooks: [
|
|
45
|
-
async (input, _toolUseID, _options) => {
|
|
46
|
-
// Type guard - only process PreToolUse events
|
|
47
|
-
if (input.hook_event_name !== 'PreToolUse') {
|
|
48
|
-
return { continue: true };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Only intercept Bash tool
|
|
52
|
-
if (input.tool_name !== 'Bash') {
|
|
53
|
-
return { continue: true };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const bashInput = input.tool_input as {
|
|
57
|
-
command: string;
|
|
58
|
-
timeout?: number;
|
|
59
|
-
description?: string;
|
|
60
|
-
run_in_background?: boolean;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
// Rewrite the command to go through term exec
|
|
64
|
-
const termCommand = `term exec ${target} '${escapeForSingleQuotes(bashInput.command)}'`;
|
|
65
|
-
|
|
66
|
-
// Create a brief description for the proxied command
|
|
67
|
-
const originalDesc = bashInput.description || bashInput.command.slice(0, 50);
|
|
68
|
-
const proxyDescription = `[term] ${originalDesc}${bashInput.command.length > 50 ? '...' : ''}`;
|
|
69
|
-
|
|
70
|
-
return {
|
|
71
|
-
continue: true,
|
|
72
|
-
hookSpecificOutput: {
|
|
73
|
-
hookEventName: 'PreToolUse',
|
|
74
|
-
updatedInput: {
|
|
75
|
-
command: termCommand,
|
|
76
|
-
timeout: bashInput.timeout,
|
|
77
|
-
description: proxyDescription,
|
|
78
|
-
run_in_background: bashInput.run_in_background,
|
|
79
|
-
},
|
|
80
|
-
permissionDecision: 'allow',
|
|
81
|
-
additionalContext: `Human can observe: tmux attach -t ${sessionName}`,
|
|
82
|
-
},
|
|
83
|
-
};
|
|
84
|
-
},
|
|
85
|
-
],
|
|
86
|
-
},
|
|
87
|
-
],
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* HookConfig type matching the Claude Agent SDK hook configuration
|
|
93
|
-
*/
|
|
94
|
-
export interface HookConfig {
|
|
95
|
-
PreToolUse?: Array<{
|
|
96
|
-
matcher: string | RegExp;
|
|
97
|
-
timeout?: number;
|
|
98
|
-
hooks: Array<HookCallback>;
|
|
99
|
-
}>;
|
|
100
|
-
PostToolUse?: Array<{
|
|
101
|
-
matcher?: string | RegExp;
|
|
102
|
-
timeout?: number;
|
|
103
|
-
hooks: Array<HookCallback>;
|
|
104
|
-
}>;
|
|
105
|
-
PermissionRequest?: Array<{
|
|
106
|
-
timeout?: number;
|
|
107
|
-
hooks: Array<HookCallback>;
|
|
108
|
-
}>;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Hook input types
|
|
113
|
-
*/
|
|
114
|
-
export interface HookInput {
|
|
115
|
-
hook_event_name: string;
|
|
116
|
-
tool_name?: string;
|
|
117
|
-
tool_input?: unknown;
|
|
118
|
-
tool_response?: unknown;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Hook callback type
|
|
123
|
-
*/
|
|
124
|
-
export type HookCallback = (
|
|
125
|
-
input: HookInput,
|
|
126
|
-
toolUseID: string | undefined,
|
|
127
|
-
options: { signal: AbortSignal }
|
|
128
|
-
) => Promise<HookOutput>;
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Hook output type
|
|
132
|
-
*/
|
|
133
|
-
export interface HookOutput {
|
|
134
|
-
continue: boolean;
|
|
135
|
-
hookSpecificOutput?: {
|
|
136
|
-
hookEventName: string;
|
|
137
|
-
updatedInput?: unknown;
|
|
138
|
-
permissionDecision?: 'allow' | 'deny' | 'ask';
|
|
139
|
-
permissionDecisionReason?: string;
|
|
140
|
-
additionalContext?: string;
|
|
141
|
-
decision?: { behavior: 'allow' | 'deny' | 'ask' };
|
|
142
|
-
};
|
|
143
|
-
}
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sandboxed Hook Preset
|
|
3
|
-
*
|
|
4
|
-
* Restricts file operations to specific directories.
|
|
5
|
-
* Intercepts PreToolUse events for file-related tools and denies operations
|
|
6
|
-
* outside the allowed paths.
|
|
7
|
-
*
|
|
8
|
-
* Usage:
|
|
9
|
-
* const hooks = sandboxedHooks({ allowedPaths: ['~/projects', '/tmp'] });
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { resolve, normalize } from 'path';
|
|
13
|
-
import { homedir } from 'os';
|
|
14
|
-
import type { SandboxedConfig } from '../../../types/genie-config.js';
|
|
15
|
-
import type { HookConfig, HookInput, HookOutput } from './collaborative.js';
|
|
16
|
-
|
|
17
|
-
export interface SandboxedHookConfig {
|
|
18
|
-
allowedPaths: string[];
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Default configuration for sandboxed hooks
|
|
23
|
-
*/
|
|
24
|
-
export const DEFAULT_SANDBOXED_CONFIG: SandboxedHookConfig = {
|
|
25
|
-
allowedPaths: ['~/projects', '/tmp'],
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Tools that access files
|
|
30
|
-
*/
|
|
31
|
-
const FILE_TOOLS = ['Read', 'Write', 'Edit', 'Glob', 'Grep'];
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Expand ~ to home directory
|
|
35
|
-
*/
|
|
36
|
-
function expandTilde(path: string): string {
|
|
37
|
-
if (path.startsWith('~/')) {
|
|
38
|
-
return homedir() + path.slice(1);
|
|
39
|
-
}
|
|
40
|
-
if (path === '~') {
|
|
41
|
-
return homedir();
|
|
42
|
-
}
|
|
43
|
-
return path;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Normalize and resolve a path, expanding ~
|
|
48
|
-
*/
|
|
49
|
-
function normalizePath(path: string): string {
|
|
50
|
-
return normalize(resolve(expandTilde(path)));
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Check if a path is within any of the allowed paths
|
|
55
|
-
*/
|
|
56
|
-
function isWithinAllowed(targetPath: string, allowedPaths: string[]): boolean {
|
|
57
|
-
const normalizedTarget = normalizePath(targetPath);
|
|
58
|
-
|
|
59
|
-
for (const allowed of allowedPaths) {
|
|
60
|
-
const normalizedAllowed = normalizePath(allowed);
|
|
61
|
-
|
|
62
|
-
// Check if target is within or equal to allowed path
|
|
63
|
-
if (
|
|
64
|
-
normalizedTarget === normalizedAllowed ||
|
|
65
|
-
normalizedTarget.startsWith(normalizedAllowed + '/')
|
|
66
|
-
) {
|
|
67
|
-
return true;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Extract the path from tool input
|
|
76
|
-
*/
|
|
77
|
-
function extractPath(toolName: string, toolInput: unknown): string | null {
|
|
78
|
-
const input = toolInput as Record<string, unknown>;
|
|
79
|
-
|
|
80
|
-
switch (toolName) {
|
|
81
|
-
case 'Read':
|
|
82
|
-
case 'Write':
|
|
83
|
-
case 'Edit':
|
|
84
|
-
return (input.file_path as string) || null;
|
|
85
|
-
case 'Glob':
|
|
86
|
-
return (input.path as string) || process.cwd();
|
|
87
|
-
case 'Grep':
|
|
88
|
-
return (input.path as string) || process.cwd();
|
|
89
|
-
default:
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Create sandboxed hooks configuration
|
|
96
|
-
*
|
|
97
|
-
* This hook intercepts PreToolUse events for file-related tools and
|
|
98
|
-
* denies operations on paths outside the allowed directories.
|
|
99
|
-
*/
|
|
100
|
-
export function sandboxedHooks(config: Partial<SandboxedConfig> = {}): HookConfig {
|
|
101
|
-
const allowedPaths = config.allowedPaths || DEFAULT_SANDBOXED_CONFIG.allowedPaths;
|
|
102
|
-
|
|
103
|
-
// Create a regex pattern that matches all file tools
|
|
104
|
-
const fileToolPattern = new RegExp(`^(${FILE_TOOLS.join('|')})$`);
|
|
105
|
-
|
|
106
|
-
return {
|
|
107
|
-
PreToolUse: [
|
|
108
|
-
{
|
|
109
|
-
matcher: fileToolPattern,
|
|
110
|
-
timeout: 30,
|
|
111
|
-
hooks: [
|
|
112
|
-
async (input: HookInput, _toolUseID, _options): Promise<HookOutput> => {
|
|
113
|
-
// Type guard - only process PreToolUse events
|
|
114
|
-
if (input.hook_event_name !== 'PreToolUse') {
|
|
115
|
-
return { continue: true };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const toolName = input.tool_name;
|
|
119
|
-
if (!toolName || !FILE_TOOLS.includes(toolName)) {
|
|
120
|
-
return { continue: true };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const targetPath = extractPath(toolName, input.tool_input);
|
|
124
|
-
|
|
125
|
-
// If we can't extract a path, allow the operation
|
|
126
|
-
// (better to be permissive than block valid operations)
|
|
127
|
-
if (!targetPath) {
|
|
128
|
-
return { continue: true };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Check if path is within allowed directories
|
|
132
|
-
if (!isWithinAllowed(targetPath, allowedPaths)) {
|
|
133
|
-
const allowedDisplay = allowedPaths.map((p) =>
|
|
134
|
-
p.startsWith('~') ? p : expandTilde(p)
|
|
135
|
-
).join(', ');
|
|
136
|
-
|
|
137
|
-
return {
|
|
138
|
-
continue: false,
|
|
139
|
-
hookSpecificOutput: {
|
|
140
|
-
hookEventName: 'PreToolUse',
|
|
141
|
-
permissionDecision: 'deny',
|
|
142
|
-
permissionDecisionReason: `Path "${targetPath}" is outside the sandbox. Allowed paths: ${allowedDisplay}`,
|
|
143
|
-
},
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
return { continue: true };
|
|
148
|
-
},
|
|
149
|
-
],
|
|
150
|
-
},
|
|
151
|
-
],
|
|
152
|
-
};
|
|
153
|
-
}
|