@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automagik/genie",
3
- "version": "4.260409.2",
3
+ "version": "4.260409.4",
4
4
  "description": "Collaborative terminal toolkit for human + AI workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genie",
3
- "version": "4.260409.2",
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"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genie-plugin",
3
- "version": "4.260409.2",
3
+ "version": "4.260409.4",
4
4
  "private": true,
5
5
  "description": "Runtime dependencies for genie bundled CLIs",
6
6
  "type": "module",
@@ -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
  }
@@ -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 sdk via JSON serialization)
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
+ });
@@ -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 hooksSettings = JSON.stringify({
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
- parts.push('--settings', escapeShellArg(hooksSettings));
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> = {}): PreToolUseHookInput {
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
- } as PreToolUseHookInput;
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<SyncHookJSONOutput> {
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<SyncHookJSONOutput>;
52
+ }) as Promise<HookOutput>;
33
53
  }
34
54
 
35
55
  /** Extract permissionDecision from gate result. */
36
- function decision(result: SyncHookJSONOutput): string {
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: SyncHookJSONOutput): string | undefined {
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 an explicit allow list is given, use it (with optional bashAllowPatterns).
60
- * 3. Fall back to PRESET_FULL (allow everything).
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,
@@ -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(agentName: string, chatId: string, env: Record<string, string>): Promise<ExecutorSession>;
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('includes turn-based instructions in system prompt when OMNI_INSTANCE is set', async () => {
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 = (queryMock.mock.calls[0] as unknown as [{ options?: { systemPrompt?: string } }])[0];
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
- // Verify turn-based prompt content
469
- expect(systemPrompt).toContain('WhatsApp');
470
- expect(systemPrompt).toContain('Alice');
471
- expect(systemPrompt).toContain('SendMessage');
472
- expect(systemPrompt).toContain('omni done');
473
- expect(systemPrompt).toContain('inst-wb');
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 [{ options?: { systemPrompt?: string } }])[0];
490
- const systemPrompt = callArgs.options?.systemPrompt ?? '';
496
+ const callArgs = (queryMock.mock.calls[0] as unknown as [{ prompt?: string }])[0];
497
+ const userPrompt = callArgs.prompt ?? '';
491
498
 
492
- expect(systemPrompt).not.toContain('WhatsApp');
493
- expect(systemPrompt).not.toContain('SendMessage');
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