@automagik/genie 4.260409.2 → 4.260409.4
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/.claude-plugin/marketplace.json +1 -1
- package/dist/genie.js +46 -38
- package/package.json +1 -1
- package/plugins/genie/.claude-plugin/plugin.json +1 -1
- package/plugins/genie/package.json +1 -1
- package/src/lib/agent-directory.ts +16 -0
- package/src/lib/agent-sync.ts +21 -1
- package/src/lib/frontmatter.test.ts +120 -0
- package/src/lib/frontmatter.ts +13 -0
- package/src/lib/provider-adapters.ts +22 -4
- package/src/lib/providers/__tests__/claude-sdk-permissions.test.ts +124 -8
- package/src/lib/providers/claude-sdk-permissions.ts +55 -2
- package/src/services/executor.ts +6 -1
- package/src/services/executors/__tests__/claude-sdk.test.ts +21 -12
- package/src/services/executors/claude-code.test.ts +56 -22
- package/src/services/executors/claude-code.ts +136 -47
- package/src/services/executors/claude-sdk.ts +24 -9
- package/src/services/executors/turn-based-prompt.ts +16 -26
- package/src/services/omni-bridge.ts +66 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "genie",
|
|
3
|
-
"version": "4.260409.
|
|
3
|
+
"version": "4.260409.4",
|
|
4
4
|
"description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Namastex Labs"
|
|
@@ -45,8 +45,15 @@ export interface DirectoryEntry {
|
|
|
45
45
|
permissions?: {
|
|
46
46
|
preset?: string;
|
|
47
47
|
allow?: string[];
|
|
48
|
+
deny?: string[];
|
|
48
49
|
bashAllowPatterns?: string[];
|
|
49
50
|
};
|
|
51
|
+
/** Tools the agent is NOT allowed to use (Claude Code --disallowedTools). */
|
|
52
|
+
disallowedTools?: string[];
|
|
53
|
+
/** Omni API scopes the agent is restricted to (e.g., 'say', 'react'). */
|
|
54
|
+
omniScopes?: string[];
|
|
55
|
+
/** Claude Code hooks configuration. */
|
|
56
|
+
hooks?: Record<string, unknown>;
|
|
50
57
|
/** Full SDK Options configuration for claude-sdk provider sessions. */
|
|
51
58
|
sdk?: SdkDirectoryConfig;
|
|
52
59
|
}
|
|
@@ -283,6 +290,9 @@ export async function edit(
|
|
|
283
290
|
| 'color'
|
|
284
291
|
| 'provider'
|
|
285
292
|
| 'permissions'
|
|
293
|
+
| 'disallowedTools'
|
|
294
|
+
| 'omniScopes'
|
|
295
|
+
| 'hooks'
|
|
286
296
|
| 'sdk'
|
|
287
297
|
>
|
|
288
298
|
>,
|
|
@@ -384,6 +394,9 @@ function roleToEntry(role: string, team?: string, metadata?: Record<string, unkn
|
|
|
384
394
|
color: metadata?.color as string | undefined,
|
|
385
395
|
provider: metadata?.provider as string | undefined,
|
|
386
396
|
permissions: metadata?.permissions as DirectoryEntry['permissions'],
|
|
397
|
+
disallowedTools: metadata?.disallowedTools as string[] | undefined,
|
|
398
|
+
omniScopes: metadata?.omniScopes as string[] | undefined,
|
|
399
|
+
hooks: metadata?.hooks as Record<string, unknown> | undefined,
|
|
387
400
|
sdk: metadata?.sdk as SdkDirectoryConfig | undefined,
|
|
388
401
|
...(metadata?.repo ? { repo: metadata.repo as string } : team ? { repo: team } : {}),
|
|
389
402
|
};
|
|
@@ -400,6 +413,9 @@ function buildMetadata(entry: DirectoryEntry): Record<string, unknown> {
|
|
|
400
413
|
if (entry.color) meta.color = entry.color;
|
|
401
414
|
if (entry.provider) meta.provider = entry.provider;
|
|
402
415
|
if (entry.permissions) meta.permissions = entry.permissions;
|
|
416
|
+
if (entry.disallowedTools) meta.disallowedTools = entry.disallowedTools;
|
|
417
|
+
if (entry.omniScopes) meta.omniScopes = entry.omniScopes;
|
|
418
|
+
if (entry.hooks) meta.hooks = entry.hooks;
|
|
403
419
|
if (entry.sdk) meta.sdk = entry.sdk;
|
|
404
420
|
return meta;
|
|
405
421
|
}
|
package/src/lib/agent-sync.ts
CHANGED
|
@@ -378,6 +378,10 @@ async function syncSingleAgent(agent: AgentInfo, result: SyncResult, workspaceRo
|
|
|
378
378
|
|
|
379
379
|
// Cast fm.sdk to the directory config type for passthrough storage
|
|
380
380
|
const sdkConfig = fm.sdk as Record<string, unknown> | undefined;
|
|
381
|
+
const permissions = fm.permissions as { allow?: string[]; deny?: string[] } | undefined;
|
|
382
|
+
const disallowedTools = fm.disallowedTools as string[] | undefined;
|
|
383
|
+
const omniScopes = fm.omniScopes as string[] | undefined;
|
|
384
|
+
const hooks = fm.hooks as Record<string, unknown> | undefined;
|
|
381
385
|
|
|
382
386
|
if (!existing) {
|
|
383
387
|
await directory.add({
|
|
@@ -389,6 +393,10 @@ async function syncSingleAgent(agent: AgentInfo, result: SyncResult, workspaceRo
|
|
|
389
393
|
description: fm.description,
|
|
390
394
|
color: fm.color,
|
|
391
395
|
provider: fm.provider,
|
|
396
|
+
permissions,
|
|
397
|
+
disallowedTools,
|
|
398
|
+
omniScopes,
|
|
399
|
+
hooks,
|
|
392
400
|
sdk: sdkConfig,
|
|
393
401
|
});
|
|
394
402
|
result.registered.push(agent.name);
|
|
@@ -404,13 +412,25 @@ async function syncSingleAgent(agent: AgentInfo, result: SyncResult, workspaceRo
|
|
|
404
412
|
description: fm.description,
|
|
405
413
|
color: fm.color,
|
|
406
414
|
provider: fm.provider,
|
|
415
|
+
permissions,
|
|
416
|
+
disallowedTools,
|
|
417
|
+
omniScopes,
|
|
418
|
+
hooks,
|
|
407
419
|
sdk: sdkConfig,
|
|
408
420
|
};
|
|
409
421
|
|
|
410
|
-
// Check if any field actually changed (deep compare
|
|
422
|
+
// Check if any field actually changed (deep compare via JSON serialization)
|
|
411
423
|
const sdkChanged = JSON.stringify(existing.sdk) !== JSON.stringify(sdkConfig);
|
|
424
|
+
const permissionsChanged = JSON.stringify(existing.permissions) !== JSON.stringify(permissions);
|
|
425
|
+
const disallowedToolsChanged = JSON.stringify(existing.disallowedTools) !== JSON.stringify(disallowedTools);
|
|
426
|
+
const omniScopesChanged = JSON.stringify(existing.omniScopes) !== JSON.stringify(omniScopes);
|
|
427
|
+
const hooksChanged = JSON.stringify(existing.hooks) !== JSON.stringify(hooks);
|
|
412
428
|
const needsUpdate =
|
|
413
429
|
sdkChanged ||
|
|
430
|
+
permissionsChanged ||
|
|
431
|
+
disallowedToolsChanged ||
|
|
432
|
+
omniScopesChanged ||
|
|
433
|
+
hooksChanged ||
|
|
414
434
|
existing.repo !== repoPath ||
|
|
415
435
|
existing.dir !== agent.dir ||
|
|
416
436
|
existing.promptMode !== (fm.promptMode ?? 'append') ||
|
|
@@ -402,3 +402,123 @@ model: opus
|
|
|
402
402
|
expect(result.sdk).toBeUndefined();
|
|
403
403
|
});
|
|
404
404
|
});
|
|
405
|
+
|
|
406
|
+
// ============================================================================
|
|
407
|
+
// Permissions, disallowedTools, omniScopes, hooks frontmatter
|
|
408
|
+
// ============================================================================
|
|
409
|
+
|
|
410
|
+
describe('parseFrontmatter — permissions and sandbox fields', () => {
|
|
411
|
+
test('parses permissions with allow and deny lists', () => {
|
|
412
|
+
const content = `---
|
|
413
|
+
name: sandboxed-agent
|
|
414
|
+
permissions:
|
|
415
|
+
allow:
|
|
416
|
+
- Read
|
|
417
|
+
- Grep
|
|
418
|
+
- "Bash(omni say *)"
|
|
419
|
+
- "Bash(git *)"
|
|
420
|
+
deny:
|
|
421
|
+
- Write
|
|
422
|
+
- Edit
|
|
423
|
+
---
|
|
424
|
+
`;
|
|
425
|
+
const result = parseFrontmatter(content);
|
|
426
|
+
expect(result.permissions).toBeDefined();
|
|
427
|
+
expect(result.permissions!.allow).toEqual(['Read', 'Grep', 'Bash(omni say *)', 'Bash(git *)']);
|
|
428
|
+
expect(result.permissions!.deny).toEqual(['Write', 'Edit']);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('parses disallowedTools array', () => {
|
|
432
|
+
const content = `---
|
|
433
|
+
name: restricted
|
|
434
|
+
disallowedTools:
|
|
435
|
+
- Agent
|
|
436
|
+
- NotebookEdit
|
|
437
|
+
---
|
|
438
|
+
`;
|
|
439
|
+
const result = parseFrontmatter(content);
|
|
440
|
+
expect(result.disallowedTools).toEqual(['Agent', 'NotebookEdit']);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test('parses omniScopes array', () => {
|
|
444
|
+
const content = `---
|
|
445
|
+
name: omni-agent
|
|
446
|
+
omniScopes:
|
|
447
|
+
- say
|
|
448
|
+
- react
|
|
449
|
+
- history
|
|
450
|
+
---
|
|
451
|
+
`;
|
|
452
|
+
const result = parseFrontmatter(content);
|
|
453
|
+
expect(result.omniScopes).toEqual(['say', 'react', 'history']);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test('parses hooks as permissive record', () => {
|
|
457
|
+
const content = `---
|
|
458
|
+
name: hooked
|
|
459
|
+
hooks:
|
|
460
|
+
PreToolUse:
|
|
461
|
+
- matcher: "*"
|
|
462
|
+
hooks:
|
|
463
|
+
- type: command
|
|
464
|
+
command: echo test
|
|
465
|
+
---
|
|
466
|
+
`;
|
|
467
|
+
const result = parseFrontmatter(content);
|
|
468
|
+
expect(result.hooks).toBeDefined();
|
|
469
|
+
expect(result.hooks!.PreToolUse).toBeDefined();
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test('missing sandbox fields result in undefined', () => {
|
|
473
|
+
const content = `---
|
|
474
|
+
name: basic
|
|
475
|
+
---
|
|
476
|
+
`;
|
|
477
|
+
const result = parseFrontmatter(content);
|
|
478
|
+
expect(result.permissions).toBeUndefined();
|
|
479
|
+
expect(result.disallowedTools).toBeUndefined();
|
|
480
|
+
expect(result.omniScopes).toBeUndefined();
|
|
481
|
+
expect(result.hooks).toBeUndefined();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test('permissions with only allow (no deny) is valid', () => {
|
|
485
|
+
const content = `---
|
|
486
|
+
permissions:
|
|
487
|
+
allow:
|
|
488
|
+
- Read
|
|
489
|
+
- Glob
|
|
490
|
+
---
|
|
491
|
+
`;
|
|
492
|
+
const result = parseFrontmatter(content);
|
|
493
|
+
expect(result.permissions).toBeDefined();
|
|
494
|
+
expect(result.permissions!.allow).toEqual(['Read', 'Glob']);
|
|
495
|
+
expect(result.permissions!.deny).toBeUndefined();
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test('all sandbox fields together parse correctly', () => {
|
|
499
|
+
const content = `---
|
|
500
|
+
name: full-sandbox
|
|
501
|
+
provider: claude-sdk
|
|
502
|
+
permissionMode: dontAsk
|
|
503
|
+
disallowedTools:
|
|
504
|
+
- Agent
|
|
505
|
+
permissions:
|
|
506
|
+
allow:
|
|
507
|
+
- Read
|
|
508
|
+
- "Bash(omni *)"
|
|
509
|
+
omniScopes:
|
|
510
|
+
- say
|
|
511
|
+
hooks:
|
|
512
|
+
PreToolUse:
|
|
513
|
+
- matcher: Bash
|
|
514
|
+
---
|
|
515
|
+
`;
|
|
516
|
+
const result = parseFrontmatter(content);
|
|
517
|
+
expect(result.name).toBe('full-sandbox');
|
|
518
|
+
expect(result.provider).toBe('claude-sdk');
|
|
519
|
+
expect(result.disallowedTools).toEqual(['Agent']);
|
|
520
|
+
expect(result.permissions!.allow).toEqual(['Read', 'Bash(omni *)']);
|
|
521
|
+
expect(result.omniScopes).toEqual(['say']);
|
|
522
|
+
expect(result.hooks).toBeDefined();
|
|
523
|
+
});
|
|
524
|
+
});
|
package/src/lib/frontmatter.ts
CHANGED
|
@@ -35,6 +35,19 @@ export const AgentFrontmatterSchema = z.object({
|
|
|
35
35
|
provider: z.enum(providerValues).optional(),
|
|
36
36
|
tools: z.array(z.string()).optional(),
|
|
37
37
|
permissionMode: z.string().optional(),
|
|
38
|
+
/** Tools the agent is NOT allowed to use (Claude Code --disallowedTools). */
|
|
39
|
+
disallowedTools: z.array(z.string()).optional(),
|
|
40
|
+
/** Claude Code permission rules — allow/deny lists with Bash() patterns. */
|
|
41
|
+
permissions: z
|
|
42
|
+
.object({
|
|
43
|
+
allow: z.array(z.string()).optional(),
|
|
44
|
+
deny: z.array(z.string()).optional(),
|
|
45
|
+
})
|
|
46
|
+
.optional(),
|
|
47
|
+
/** Omni API scopes the agent is restricted to (e.g., 'say', 'react'). */
|
|
48
|
+
omniScopes: z.array(z.string()).optional(),
|
|
49
|
+
/** Claude Code hooks configuration — permissive record for forward compatibility. */
|
|
50
|
+
hooks: z.record(z.unknown()).optional(),
|
|
38
51
|
/** SDK configuration block — permissive record so new SDK options don't require parser updates. */
|
|
39
52
|
sdk: z.record(z.unknown()).optional(),
|
|
40
53
|
});
|
|
@@ -83,6 +83,10 @@ export interface SpawnParams {
|
|
|
83
83
|
initialPrompt?: string;
|
|
84
84
|
/** Display name for the CC session (emits --name). Used in /resume and terminal title. */
|
|
85
85
|
name?: string;
|
|
86
|
+
/** Claude Code permissions (allow/deny lists with Bash() patterns). Merged into --settings. */
|
|
87
|
+
permissions?: { allow?: string[]; deny?: string[] };
|
|
88
|
+
/** Tools the agent is NOT allowed to use (emits --disallowedTools). */
|
|
89
|
+
disallowedTools?: string[];
|
|
86
90
|
/** OTel receiver port to inject as OTEL_EXPORTER_OTLP_ENDPOINT. Undefined = skip injection. */
|
|
87
91
|
otelPort?: number;
|
|
88
92
|
/** Whether to log user prompts via OTel (default: true). */
|
|
@@ -349,18 +353,32 @@ export function buildClaudeCommand(params: SpawnParams): LaunchCommand {
|
|
|
349
353
|
|
|
350
354
|
appendSystemPromptFlags(parts, params);
|
|
351
355
|
|
|
352
|
-
// Inject hook dispatch via --settings (deep-merges with existing settings)
|
|
356
|
+
// Inject hook dispatch + permissions via --settings (deep-merges with existing settings)
|
|
353
357
|
const dispatchCmd = buildDispatchCommand();
|
|
354
358
|
const hookEntry = { type: 'command', command: dispatchCmd, timeout: 15 };
|
|
355
|
-
const
|
|
359
|
+
const settingsObj: Record<string, unknown> = {
|
|
356
360
|
hooks: {
|
|
357
361
|
PreToolUse: [{ matcher: '*', hooks: [hookEntry] }],
|
|
358
362
|
PostToolUse: [{ matcher: '*', hooks: [hookEntry] }],
|
|
359
363
|
UserPromptSubmit: [{ hooks: [hookEntry] }],
|
|
360
364
|
Stop: [{ hooks: [hookEntry] }],
|
|
361
365
|
},
|
|
362
|
-
}
|
|
363
|
-
|
|
366
|
+
};
|
|
367
|
+
// Merge permissions into settings when provided
|
|
368
|
+
if (params.permissions) {
|
|
369
|
+
const perms: Record<string, string[]> = {};
|
|
370
|
+
if (params.permissions.allow?.length) perms.allow = params.permissions.allow;
|
|
371
|
+
if (params.permissions.deny?.length) perms.deny = params.permissions.deny;
|
|
372
|
+
if (Object.keys(perms).length > 0) settingsObj.permissions = perms;
|
|
373
|
+
}
|
|
374
|
+
parts.push('--settings', escapeShellArg(JSON.stringify(settingsObj)));
|
|
375
|
+
|
|
376
|
+
// Emit --disallowedTools when the agent declares tool restrictions
|
|
377
|
+
if (params.disallowedTools?.length) {
|
|
378
|
+
for (const tool of params.disallowedTools) {
|
|
379
|
+
parts.push('--disallowedTools', escapeShellArg(tool));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
364
382
|
|
|
365
383
|
if (params.extraArgs) {
|
|
366
384
|
for (const arg of params.extraArgs) parts.push(escapeShellArg(arg));
|
|
@@ -1,15 +1,35 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test';
|
|
2
|
-
import type { PreToolUseHookInput, SyncHookJSONOutput } from '@anthropic-ai/claude-agent-sdk';
|
|
3
2
|
import {
|
|
4
3
|
PRESET_CHAT_ONLY,
|
|
5
4
|
PRESET_FULL,
|
|
6
5
|
PRESET_READ_ONLY,
|
|
7
6
|
createPermissionGate,
|
|
7
|
+
resolvePermissionConfig,
|
|
8
8
|
resolvePreset,
|
|
9
|
+
translateClaudeCodePermissions,
|
|
9
10
|
} from '../claude-sdk-permissions.js';
|
|
10
11
|
|
|
12
|
+
// NOTE: We intentionally avoid `import type` from @anthropic-ai/claude-agent-sdk here.
|
|
13
|
+
// Bun's test runner may resolve the real module even for type-only imports, poisoning the
|
|
14
|
+
// process-global mock.module cache used by claude-sdk.test.ts and claude-sdk-resume.test.ts.
|
|
15
|
+
// Instead we use inline structural types that match the SDK's shapes.
|
|
16
|
+
|
|
17
|
+
type HookInput = {
|
|
18
|
+
hook_event_name: string;
|
|
19
|
+
tool_name: string;
|
|
20
|
+
tool_input: Record<string, unknown>;
|
|
21
|
+
tool_use_id: string;
|
|
22
|
+
session_id: string;
|
|
23
|
+
transcript_path: string;
|
|
24
|
+
cwd: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type HookOutput = {
|
|
28
|
+
hookSpecificOutput?: Record<string, unknown>;
|
|
29
|
+
};
|
|
30
|
+
|
|
11
31
|
/** Build a minimal PreToolUseHookInput for testing. */
|
|
12
|
-
function hookInput(toolName: string, toolInput: Record<string, unknown> = {}):
|
|
32
|
+
function hookInput(toolName: string, toolInput: Record<string, unknown> = {}): HookInput {
|
|
13
33
|
return {
|
|
14
34
|
hook_event_name: 'PreToolUse',
|
|
15
35
|
tool_name: toolName,
|
|
@@ -18,7 +38,7 @@ function hookInput(toolName: string, toolInput: Record<string, unknown> = {}): P
|
|
|
18
38
|
session_id: 'test',
|
|
19
39
|
transcript_path: '',
|
|
20
40
|
cwd: '',
|
|
21
|
-
}
|
|
41
|
+
};
|
|
22
42
|
}
|
|
23
43
|
|
|
24
44
|
/** Call the gate with standard test args and return the result. */
|
|
@@ -26,19 +46,19 @@ async function callGate(
|
|
|
26
46
|
gate: ReturnType<typeof createPermissionGate>,
|
|
27
47
|
toolName: string,
|
|
28
48
|
toolInput: Record<string, unknown> = {},
|
|
29
|
-
): Promise<
|
|
30
|
-
return gate(hookInput(toolName, toolInput), 'test', {
|
|
49
|
+
): Promise<HookOutput> {
|
|
50
|
+
return gate(hookInput(toolName, toolInput) as any, 'test', {
|
|
31
51
|
signal: new AbortController().signal,
|
|
32
|
-
}) as Promise<
|
|
52
|
+
}) as Promise<HookOutput>;
|
|
33
53
|
}
|
|
34
54
|
|
|
35
55
|
/** Extract permissionDecision from gate result. */
|
|
36
|
-
function decision(result:
|
|
56
|
+
function decision(result: HookOutput): string {
|
|
37
57
|
return (result.hookSpecificOutput as any).permissionDecision;
|
|
38
58
|
}
|
|
39
59
|
|
|
40
60
|
/** Extract permissionDecisionReason from gate result. */
|
|
41
|
-
function reason(result:
|
|
61
|
+
function reason(result: HookOutput): string | undefined {
|
|
42
62
|
return (result.hookSpecificOutput as any).permissionDecisionReason;
|
|
43
63
|
}
|
|
44
64
|
|
|
@@ -249,3 +269,99 @@ describe('createPermissionGate', () => {
|
|
|
249
269
|
});
|
|
250
270
|
});
|
|
251
271
|
});
|
|
272
|
+
|
|
273
|
+
describe('translateClaudeCodePermissions', () => {
|
|
274
|
+
it('extracts Bash() patterns into bashAllowPatterns as regex', () => {
|
|
275
|
+
const result = translateClaudeCodePermissions({
|
|
276
|
+
allow: ['Read', 'Grep', 'Bash(omni say *)'],
|
|
277
|
+
});
|
|
278
|
+
expect(result.allow).toContain('Read');
|
|
279
|
+
expect(result.allow).toContain('Grep');
|
|
280
|
+
expect(result.allow).toContain('Bash');
|
|
281
|
+
expect(result.bashAllowPatterns).toBeDefined();
|
|
282
|
+
expect(result.bashAllowPatterns!.length).toBe(1);
|
|
283
|
+
// Should match "omni say hello"
|
|
284
|
+
expect('omni say hello').toMatch(new RegExp(result.bashAllowPatterns![0]));
|
|
285
|
+
// Should not match "rm -rf /"
|
|
286
|
+
expect('rm -rf /').not.toMatch(new RegExp(result.bashAllowPatterns![0]));
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('handles multiple Bash() patterns', () => {
|
|
290
|
+
const result = translateClaudeCodePermissions({
|
|
291
|
+
allow: ['Bash(git *)', 'Bash(omni *)'],
|
|
292
|
+
});
|
|
293
|
+
expect(result.allow).toEqual(['Bash']);
|
|
294
|
+
expect(result.bashAllowPatterns!.length).toBe(2);
|
|
295
|
+
expect('git status').toMatch(new RegExp(result.bashAllowPatterns![0]));
|
|
296
|
+
expect('omni send hi').toMatch(new RegExp(result.bashAllowPatterns![1]));
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('handles bare tool names without Bash() patterns', () => {
|
|
300
|
+
const result = translateClaudeCodePermissions({
|
|
301
|
+
allow: ['Read', 'Glob', 'Grep'],
|
|
302
|
+
});
|
|
303
|
+
expect(result.allow).toEqual(['Read', 'Glob', 'Grep']);
|
|
304
|
+
expect(result.bashAllowPatterns).toBeUndefined();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('handles bare Bash (no parentheses) as unrestricted', () => {
|
|
308
|
+
const result = translateClaudeCodePermissions({
|
|
309
|
+
allow: ['Read', 'Bash'],
|
|
310
|
+
});
|
|
311
|
+
expect(result.allow).toEqual(['Read', 'Bash']);
|
|
312
|
+
expect(result.bashAllowPatterns).toBeUndefined();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('defaults to wildcard allow when allow list is empty', () => {
|
|
316
|
+
const result = translateClaudeCodePermissions({ allow: [] });
|
|
317
|
+
expect(result.allow).toEqual(['*']);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('defaults to wildcard allow when no allow provided', () => {
|
|
321
|
+
const result = translateClaudeCodePermissions({});
|
|
322
|
+
expect(result.allow).toEqual(['*']);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe('resolvePermissionConfig — Claude Code format detection', () => {
|
|
327
|
+
it('detects Claude Code format with Bash() patterns and translates', () => {
|
|
328
|
+
const result = resolvePermissionConfig({
|
|
329
|
+
allow: ['Read', 'Bash(omni say *)'],
|
|
330
|
+
});
|
|
331
|
+
expect(result.allow).toContain('Read');
|
|
332
|
+
expect(result.allow).toContain('Bash');
|
|
333
|
+
expect(result.bashAllowPatterns).toBeDefined();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('passes through legacy SDK format (with bashAllowPatterns) unchanged', () => {
|
|
337
|
+
const result = resolvePermissionConfig({
|
|
338
|
+
allow: ['Read', 'Bash'],
|
|
339
|
+
bashAllowPatterns: ['^git\\s'],
|
|
340
|
+
});
|
|
341
|
+
expect(result.allow).toEqual(['Read', 'Bash']);
|
|
342
|
+
expect(result.bashAllowPatterns).toEqual(['^git\\s']);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('resolves preset even when other fields present', () => {
|
|
346
|
+
const result = resolvePermissionConfig({
|
|
347
|
+
preset: 'read-only',
|
|
348
|
+
allow: ['Bash'],
|
|
349
|
+
});
|
|
350
|
+
expect(result).toBe(PRESET_READ_ONLY);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('falls back to PRESET_FULL when no permissions', () => {
|
|
354
|
+
expect(resolvePermissionConfig()).toBe(PRESET_FULL);
|
|
355
|
+
expect(resolvePermissionConfig(undefined)).toBe(PRESET_FULL);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('detects Claude Code format with deny field', () => {
|
|
359
|
+
const result = resolvePermissionConfig({
|
|
360
|
+
allow: ['Read', 'Glob'],
|
|
361
|
+
deny: ['Write'],
|
|
362
|
+
});
|
|
363
|
+
// deny triggers CC format detection → translateClaudeCodePermissions
|
|
364
|
+
expect(result.allow).toContain('Read');
|
|
365
|
+
expect(result.allow).toContain('Glob');
|
|
366
|
+
});
|
|
367
|
+
});
|
|
@@ -51,22 +51,75 @@ export function resolvePreset(name: string): PermissionConfig {
|
|
|
51
51
|
return preset;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Translate Claude Code format permissions (Bash() patterns) to SDK PermissionConfig.
|
|
56
|
+
*
|
|
57
|
+
* Claude Code format uses `allow: ["Read", "Bash(git *)"]` where `Bash(...)` patterns
|
|
58
|
+
* define allowed bash commands. The SDK uses separate `allow` (tool names) and
|
|
59
|
+
* `bashAllowPatterns` (regex) fields.
|
|
60
|
+
*
|
|
61
|
+
* Translation rules:
|
|
62
|
+
* - `Bash(pattern)` → extract pattern, convert glob-style `*` to regex `.*`, add to bashAllowPatterns
|
|
63
|
+
* - `Bash` (bare) → add "Bash" to allow list (unrestricted bash)
|
|
64
|
+
* - Other strings → add to allow list as tool names
|
|
65
|
+
*/
|
|
66
|
+
export function translateClaudeCodePermissions(ccPerms: {
|
|
67
|
+
allow?: string[];
|
|
68
|
+
deny?: string[];
|
|
69
|
+
}): PermissionConfig {
|
|
70
|
+
const toolAllow: string[] = [];
|
|
71
|
+
const bashPatterns: string[] = [];
|
|
72
|
+
|
|
73
|
+
for (const entry of ccPerms.allow ?? []) {
|
|
74
|
+
const bashMatch = entry.match(/^Bash\((.+)\)$/);
|
|
75
|
+
if (bashMatch) {
|
|
76
|
+
// Bash(pattern) → convert to regex: escape regex chars except *, then replace * with .*
|
|
77
|
+
const glob = bashMatch[1];
|
|
78
|
+
const regex = `^${glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`;
|
|
79
|
+
bashPatterns.push(regex);
|
|
80
|
+
// Ensure Bash is in the allow list so the gate checks patterns
|
|
81
|
+
if (!toolAllow.includes('Bash')) toolAllow.push('Bash');
|
|
82
|
+
} else {
|
|
83
|
+
toolAllow.push(entry);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
allow: toolAllow.length > 0 ? toolAllow : ['*'],
|
|
89
|
+
bashAllowPatterns: bashPatterns.length > 0 ? bashPatterns : undefined,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Detect whether a permissions object uses Claude Code format (has Bash() patterns in allow). */
|
|
94
|
+
function isClaudeCodeFormat(permissions: Record<string, unknown>): boolean {
|
|
95
|
+
if (Array.isArray(permissions.allow)) {
|
|
96
|
+
return permissions.allow.some((entry: unknown) => typeof entry === 'string' && /^Bash\(.+\)$/.test(entry));
|
|
97
|
+
}
|
|
98
|
+
// Claude Code format also uses deny (not present in legacy SDK format)
|
|
99
|
+
return 'deny' in permissions && !('preset' in permissions) && !('bashAllowPatterns' in permissions);
|
|
100
|
+
}
|
|
101
|
+
|
|
54
102
|
/**
|
|
55
103
|
* Resolve a PermissionConfig from an agent entry's optional permissions field.
|
|
56
104
|
*
|
|
57
105
|
* Resolution order:
|
|
58
106
|
* 1. If a preset name is specified, resolve it.
|
|
59
|
-
* 2. If
|
|
60
|
-
* 3.
|
|
107
|
+
* 2. If Claude Code format (has Bash() patterns or deny), translate to SDK format.
|
|
108
|
+
* 3. If an explicit allow list is given, use it (with optional bashAllowPatterns).
|
|
109
|
+
* 4. Fall back to PRESET_FULL (allow everything).
|
|
61
110
|
*/
|
|
62
111
|
export function resolvePermissionConfig(permissions?: {
|
|
63
112
|
preset?: string;
|
|
64
113
|
allow?: string[];
|
|
114
|
+
deny?: string[];
|
|
65
115
|
bashAllowPatterns?: string[];
|
|
66
116
|
}): PermissionConfig {
|
|
67
117
|
if (permissions?.preset) {
|
|
68
118
|
return resolvePreset(permissions.preset);
|
|
69
119
|
}
|
|
120
|
+
if (permissions && isClaudeCodeFormat(permissions)) {
|
|
121
|
+
return translateClaudeCodePermissions(permissions);
|
|
122
|
+
}
|
|
70
123
|
if (permissions?.allow) {
|
|
71
124
|
return {
|
|
72
125
|
allow: permissions.allow,
|
package/src/services/executor.ts
CHANGED
|
@@ -43,7 +43,12 @@ export type NatsPublishFn = (topic: string, payload: string) => void;
|
|
|
43
43
|
|
|
44
44
|
/** Pluggable executor backend for the Omni bridge. TODO: merge into ExecutorProvider. */
|
|
45
45
|
export interface IExecutor {
|
|
46
|
-
spawn(
|
|
46
|
+
spawn(
|
|
47
|
+
agentName: string,
|
|
48
|
+
chatId: string,
|
|
49
|
+
env: Record<string, string>,
|
|
50
|
+
initialMessage?: string,
|
|
51
|
+
): Promise<ExecutorSession>;
|
|
47
52
|
deliver(session: ExecutorSession, message: OmniMessage): Promise<void>;
|
|
48
53
|
shutdown(session: ExecutorSession): Promise<void>;
|
|
49
54
|
isAlive(session: ExecutorSession): Promise<boolean>;
|
|
@@ -448,7 +448,7 @@ describe('ClaudeSdkOmniExecutor', () => {
|
|
|
448
448
|
resetAllMocks();
|
|
449
449
|
});
|
|
450
450
|
|
|
451
|
-
it('
|
|
451
|
+
it('injects turn-based instructions into user message (not system prompt) when OMNI_INSTANCE is set', async () => {
|
|
452
452
|
const env = { OMNI_INSTANCE: 'inst-wb', OMNI_CHAT: 'chat-wb', OMNI_AGENT: 'bot' };
|
|
453
453
|
const session = await executor.spawn('test-agent', 'chat-wb', env);
|
|
454
454
|
|
|
@@ -462,15 +462,22 @@ describe('ClaudeSdkOmniExecutor', () => {
|
|
|
462
462
|
await executor.waitForDeliveries(session.id);
|
|
463
463
|
|
|
464
464
|
expect(queryMock).toHaveBeenCalledTimes(1);
|
|
465
|
-
const callArgs = (
|
|
465
|
+
const callArgs = (
|
|
466
|
+
queryMock.mock.calls[0] as unknown as [{ prompt?: string; options?: { systemPrompt?: string } }]
|
|
467
|
+
)[0];
|
|
468
|
+
const userPrompt = callArgs.prompt ?? '';
|
|
466
469
|
const systemPrompt = callArgs.options?.systemPrompt ?? '';
|
|
467
470
|
|
|
468
|
-
//
|
|
469
|
-
expect(
|
|
470
|
-
expect(
|
|
471
|
-
expect(
|
|
472
|
-
expect(
|
|
473
|
-
expect(
|
|
471
|
+
// Turn context goes in the user message, NOT the system prompt
|
|
472
|
+
expect(userPrompt).toContain('WhatsApp Turn');
|
|
473
|
+
expect(userPrompt).toContain('Alice');
|
|
474
|
+
expect(userPrompt).toContain('omni say');
|
|
475
|
+
expect(userPrompt).toContain('omni done');
|
|
476
|
+
expect(userPrompt).toContain('inst-wb');
|
|
477
|
+
expect(userPrompt).toContain('Hello from WhatsApp');
|
|
478
|
+
// System prompt should NOT contain turn instructions
|
|
479
|
+
expect(systemPrompt).not.toContain('WhatsApp Turn');
|
|
480
|
+
expect(systemPrompt).not.toContain('omni done');
|
|
474
481
|
});
|
|
475
482
|
|
|
476
483
|
it('does NOT include turn-based instructions when OMNI_INSTANCE is absent', async () => {
|
|
@@ -486,11 +493,13 @@ describe('ClaudeSdkOmniExecutor', () => {
|
|
|
486
493
|
await executor.waitForDeliveries(session.id);
|
|
487
494
|
|
|
488
495
|
expect(queryMock).toHaveBeenCalledTimes(1);
|
|
489
|
-
const callArgs = (queryMock.mock.calls[0] as unknown as [{
|
|
490
|
-
const
|
|
496
|
+
const callArgs = (queryMock.mock.calls[0] as unknown as [{ prompt?: string }])[0];
|
|
497
|
+
const userPrompt = callArgs.prompt ?? '';
|
|
491
498
|
|
|
492
|
-
expect(
|
|
493
|
-
expect(
|
|
499
|
+
expect(userPrompt).not.toContain('WhatsApp Turn');
|
|
500
|
+
expect(userPrompt).not.toContain('omni say');
|
|
501
|
+
// User message is passed through as-is
|
|
502
|
+
expect(userPrompt).toBe('Hello from CLI');
|
|
494
503
|
});
|
|
495
504
|
});
|
|
496
505
|
|