@nt-ai-lab/deterministic-agent-workflow-cli 0.4.0 → 0.4.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.
@@ -19,7 +19,7 @@ export function createWorkflowCli(config) {
19
19
  const configuredWorkflowEventsDbPath = processDeps.getEnv('WORKFLOW_EVENTS_DB');
20
20
  const workflowEventsDbPath = configuredWorkflowEventsDbPath !== undefined && configuredWorkflowEventsDbPath !== ''
21
21
  ? configuredWorkflowEventsDbPath
22
- : join(readEnvVar('HOME'), '.workflow-events.db');
22
+ : join(readEnvVar('HOME'), 'ai-workflow-database', '.workflow-events.db');
23
23
  const store = processDeps.buildStore(workflowEventsDbPath);
24
24
  const now = () => new Date().toISOString();
25
25
  const platformCtx = {
@@ -149,7 +149,10 @@ function handleRoute(engine, engineDeps, config, args, routeName, readStdin, get
149
149
  switch (routeDef.type) {
150
150
  case 'session-start': {
151
151
  const transcriptPath = getSessionTranscriptPath === undefined ? '' : getSessionTranscriptPath();
152
- return engineResultToRunnerResult(engine.startSession(resolveSessionId(), transcriptPath, getSessionRepository?.()));
152
+ const repository = getSessionRepository === undefined ? '' : getSessionRepository();
153
+ if (repository === undefined)
154
+ throw new TypeError('repository must be a non-empty string.');
155
+ return engineResultToRunnerResult(engine.startSession(resolveSessionId(), transcriptPath, repository));
153
156
  }
154
157
  case 'transition':
155
158
  return engineResultToRunnerResult(engine.transition(resolveSessionId(), config.workflowDefinition.stateSchema.parse(resolveTarget())));
@@ -191,7 +194,10 @@ function handleHook(engine, resolvedHandler, readStdin) {
191
194
  exitCode: EXIT_ALLOW
192
195
  };
193
196
  }
194
- return engineResultToRunnerResult(engine.startSession(common.session_id, common.transcript_path, getRepositoryName(common.cwd)));
197
+ const repository = getRepositoryName(common.cwd);
198
+ if (repository === undefined)
199
+ throw new TypeError('repository must be a non-empty string.');
200
+ return engineResultToRunnerResult(engine.startSession(common.session_id, common.transcript_path, repository));
195
201
  }
196
202
  if (!engine.hasSession(common.session_id))
197
203
  return {
@@ -1,7 +1,8 @@
1
1
  import type { BaseWorkflowState, EngineResult, RehydratableWorkflow, WorkflowEngine } from '@nt-ai-lab/deterministic-agent-workflow-engine';
2
2
  import type { BashForbiddenConfig } from '@nt-ai-lab/deterministic-agent-workflow-dsl';
3
+ type PreToolUseEngine<TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState<TStateName>, TDeps, TStateName extends string, TOperation extends string> = Pick<WorkflowEngine<TWorkflow, TState, TDeps, TStateName, TOperation>, 'transaction' | 'checkBash' | 'checkWrite'>;
3
4
  /** @riviere-role value-object */
4
- export type PreToolUseHandlerFn<TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState<TStateName>, TDeps, TStateName extends string = string, TOperation extends string = string> = (engine: WorkflowEngine<TWorkflow, TState, TDeps, TStateName, TOperation>, sessionId: string, toolName: string, toolInput: Record<string, unknown>) => EngineResult;
5
+ export type PreToolUseHandlerFn<TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState<TStateName>, TDeps, TStateName extends string = string, TOperation extends string = string> = (engine: PreToolUseEngine<TWorkflow, TState, TDeps, TStateName, TOperation>, sessionId: string, toolName: string, toolInput: Record<string, unknown>) => EngineResult;
5
6
  /** @riviere-role value-object */
6
7
  export type CustomPreToolUseGate<TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState<TStateName>, TStateName extends string = string> = {
7
8
  readonly name: string;
@@ -19,3 +20,4 @@ export type PreToolUseHandlerConfig<TWorkflow extends RehydratableWorkflow<TStat
19
20
  };
20
21
  /** @riviere-role domain-service */
21
22
  export declare function createPreToolUseHandler<TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState<TStateName>, TDeps, TStateName extends string = string, TOperation extends string = string>(config: PreToolUseHandlerConfig<TWorkflow, TState, TStateName>): PreToolUseHandlerFn<TWorkflow, TState, TDeps, TStateName, TOperation>;
23
+ export {};
@@ -1,12 +1,50 @@
1
+ const BASH_TOOL_NAMES = ['Bash', 'bash'];
2
+ const WRITE_TOOL_NAMES = [
3
+ // Claude Code
4
+ 'Write', 'Edit', 'MultiEdit', 'NotebookEdit',
5
+ // OpenCode and Codex
6
+ 'write', 'edit', 'apply_patch',
7
+ ];
1
8
  /** @riviere-role domain-service */
2
9
  export function createPreToolUseHandler(config) {
3
10
  return (engine, sessionId, toolName, toolInput) => {
4
- const filePath = extractFilePath(toolInput);
5
11
  const command = extractCommand(toolInput);
12
+ const isWriteTool = WRITE_TOOL_NAMES.includes(toolName);
13
+ const filePaths = isWriteTool ? extractFilePaths(toolName, toolInput) : [extractFilePath(toolInput)];
14
+ const gateResult = checkCustomGates(config, engine, sessionId, toolName, command, filePaths);
15
+ if (gateResult !== undefined)
16
+ return gateResult;
17
+ if (BASH_TOOL_NAMES.includes(toolName))
18
+ return engine.checkBash(sessionId, toolName, command, config.bashForbidden);
19
+ if (!isWriteTool) {
20
+ return {
21
+ type: 'success',
22
+ output: '',
23
+ };
24
+ }
25
+ if (filePaths.length === 0) {
26
+ return {
27
+ type: 'blocked',
28
+ output: `Cannot determine every file edited by ${toolName}.`,
29
+ };
30
+ }
31
+ for (const filePath of filePaths) {
32
+ const result = engine.checkWrite(sessionId, toolName, filePath, config.isWriteAllowed);
33
+ if (result.type === 'blocked')
34
+ return result;
35
+ }
36
+ return {
37
+ type: 'success',
38
+ output: '',
39
+ };
40
+ };
41
+ }
42
+ function checkCustomGates(config, engine, sessionId, toolName, command, filePaths) {
43
+ for (const filePath of filePaths.length === 0 ? [''] : filePaths) {
6
44
  const ctx = {
7
45
  toolName,
8
46
  filePath,
9
- command
47
+ command,
10
48
  };
11
49
  for (const gate of config.customGates ?? []) {
12
50
  const result = engine.transaction(sessionId, `hook:${gate.name}`, (workflow) => {
@@ -15,25 +53,40 @@ export function createPreToolUseHandler(config) {
15
53
  return { pass: true };
16
54
  return {
17
55
  pass: false,
18
- reason: check
56
+ reason: check,
19
57
  };
20
58
  });
21
59
  if (result.type === 'blocked')
22
60
  return result;
23
61
  }
24
- const writeCheck = engine.checkWrite(sessionId, toolName, filePath, config.isWriteAllowed);
25
- if (writeCheck.type === 'blocked')
26
- return writeCheck;
27
- return engine.checkBash(sessionId, toolName, command, config.bashForbidden);
28
- };
62
+ }
63
+ return undefined;
29
64
  }
30
65
  function extractFilePath(toolInput) {
31
66
  return resolveStringField(toolInput['file_path'])
67
+ || resolveStringField(toolInput['filePath'])
68
+ || resolveStringField(toolInput['notebook_path'])
32
69
  || resolveStringField(toolInput['path'])
33
70
  || resolveStringField(toolInput['pattern']);
34
71
  }
72
+ function extractFilePaths(toolName, toolInput) {
73
+ const filePath = extractFilePath(toolInput);
74
+ if (filePath.length > 0)
75
+ return [filePath];
76
+ if (toolName !== 'apply_patch')
77
+ return [];
78
+ const patchText = resolveStringField(toolInput['patchText']) || resolveStringField(toolInput['command']);
79
+ const paths = new Set();
80
+ for (const line of patchText.split('\n')) {
81
+ const match = /^\*\*\* (?:Update|Add|Delete) File: (.+)$|^\*\*\* Move to: (.+)$/.exec(line);
82
+ const path = match?.[1] ?? match?.[2];
83
+ if (path !== undefined && path.trim().length > 0)
84
+ paths.add(path.trim());
85
+ }
86
+ return [...paths];
87
+ }
35
88
  function extractCommand(toolInput) {
36
- return resolveStringField(toolInput['command']);
89
+ return resolveStringField(toolInput['command']) || resolveStringField(toolInput['patchText']);
37
90
  }
38
91
  function resolveStringField(value) {
39
92
  if (value === undefined || value === null)
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it, vi, } from 'vitest';
2
+ import { createPreToolUseHandler } from './pre-tool-use-handler.js';
3
+ const success = {
4
+ type: 'success',
5
+ output: '',
6
+ };
7
+ function createEngine() {
8
+ const checkBash = vi.fn((...args) => {
9
+ void args;
10
+ return success;
11
+ });
12
+ const checkWrite = vi.fn((...args) => {
13
+ void args;
14
+ return success;
15
+ });
16
+ return {
17
+ engine: {
18
+ transaction: vi.fn(() => success),
19
+ checkBash,
20
+ checkWrite,
21
+ },
22
+ checkBash,
23
+ checkWrite,
24
+ };
25
+ }
26
+ function createHandler() {
27
+ return createPreToolUseHandler({
28
+ bashForbidden: { commands: [] },
29
+ isWriteAllowed: () => true,
30
+ });
31
+ }
32
+ describe('createPreToolUseHandler', () => {
33
+ it.each([
34
+ ['Write', { file_path: 'src/file.ts' }],
35
+ ['Edit', { file_path: 'src/file.ts' }],
36
+ ['MultiEdit', { file_path: 'src/file.ts' }],
37
+ ['NotebookEdit', { notebook_path: 'src/notebook.ipynb' }],
38
+ ])('checks the Claude Code %s tool as a write', (toolName, toolInput) => {
39
+ const { engine, checkWrite, } = createEngine();
40
+ createHandler()(engine, 'session-1', toolName, toolInput);
41
+ expect(checkWrite).toHaveBeenCalledOnce();
42
+ expect(checkWrite).toHaveBeenCalledWith('session-1', toolName, toolName === 'NotebookEdit' ? 'src/notebook.ipynb' : 'src/file.ts', expect.any(Function));
43
+ });
44
+ it('checks OpenCode writes and every path in an apply_patch input', () => {
45
+ const { engine, checkWrite, } = createEngine();
46
+ const handler = createHandler();
47
+ handler(engine, 'session-1', 'write', { filePath: 'src/file.ts' });
48
+ handler(engine, 'session-1', 'edit', { filePath: 'src/file.ts' });
49
+ handler(engine, 'session-1', 'apply_patch', { patchText: '*** Begin Patch\n*** Update File: src/old.ts\n*** Move to: src/new.ts\n*** End Patch' });
50
+ expect(checkWrite.mock.calls.map(([, toolName, filePath]) => [toolName, filePath])).toStrictEqual([
51
+ ['write', 'src/file.ts'],
52
+ ['edit', 'src/file.ts'],
53
+ ['apply_patch', 'src/old.ts'],
54
+ ['apply_patch', 'src/new.ts'],
55
+ ]);
56
+ });
57
+ it('checks Claude Code and OpenCode Bash tools through Bash policy', () => {
58
+ const { engine, checkBash, checkWrite, } = createEngine();
59
+ const handler = createHandler();
60
+ handler(engine, 'session-1', 'Bash', { command: 'pwd' });
61
+ handler(engine, 'session-1', 'bash', { command: 'pwd' });
62
+ expect(checkBash).toHaveBeenCalledTimes(2);
63
+ expect(checkWrite).not.toHaveBeenCalled();
64
+ });
65
+ it('allows non-write tools without calling write policy', () => {
66
+ const { engine, checkWrite, } = createEngine();
67
+ const result = createHandler()(engine, 'session-1', 'workflow', { operation: 'record-issue' });
68
+ expect(result).toStrictEqual(success);
69
+ expect(checkWrite).not.toHaveBeenCalled();
70
+ });
71
+ it('blocks an apply_patch tool when no edited paths can be determined', () => {
72
+ const { engine, checkWrite, } = createEngine();
73
+ const result = createHandler()(engine, 'session-1', 'apply_patch', { patchText: 'not a patch' });
74
+ expect(result).toStrictEqual({
75
+ type: 'blocked',
76
+ output: 'Cannot determine every file edited by apply_patch.',
77
+ });
78
+ expect(checkWrite).not.toHaveBeenCalled();
79
+ });
80
+ });
@@ -1,19 +1,19 @@
1
1
  import { z } from 'zod';
2
2
  const hookCommonInputSchema = z.object({
3
- session_id: z.string(),
4
- transcript_path: z.string(),
5
- cwd: z.string(),
3
+ session_id: z.string().trim().min(1),
4
+ transcript_path: z.string().trim().min(1),
5
+ cwd: z.string().trim().min(1),
6
6
  permission_mode: z.string().optional(),
7
- hook_event_name: z.string(),
7
+ hook_event_name: z.string().trim().min(1),
8
8
  });
9
9
  const preToolUseInputSchema = hookCommonInputSchema.extend({
10
- tool_name: z.string(),
10
+ tool_name: z.string().trim().min(1),
11
11
  tool_input: z.record(z.unknown()),
12
- tool_use_id: z.string(),
12
+ tool_use_id: z.string().trim().min(1),
13
13
  });
14
14
  const subagentStartInputSchema = hookCommonInputSchema.extend({
15
- agent_id: z.string(),
16
- agent_type: z.string(),
15
+ agent_id: z.string().trim().min(1),
16
+ agent_type: z.string().trim().min(1),
17
17
  });
18
18
  const teammateIdleInputSchema = hookCommonInputSchema.extend({ teammate_name: z.string().optional(), });
19
19
  export { hookCommonInputSchema, preToolUseInputSchema, subagentStartInputSchema, teammateIdleInputSchema, };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nt-ai-lab/deterministic-agent-workflow-cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "repository": {
@@ -17,9 +17,9 @@
17
17
  ],
18
18
  "dependencies": {
19
19
  "zod": "^3.25.76",
20
- "@nt-ai-lab/deterministic-agent-workflow-engine": "0.4.0",
21
- "@nt-ai-lab/deterministic-agent-workflow-dsl": "0.4.0",
22
- "@nt-ai-lab/deterministic-agent-workflow-event-store": "0.4.0"
20
+ "@nt-ai-lab/deterministic-agent-workflow-dsl": "0.4.2",
21
+ "@nt-ai-lab/deterministic-agent-workflow-engine": "0.4.2",
22
+ "@nt-ai-lab/deterministic-agent-workflow-event-store": "0.4.2"
23
23
  },
24
24
  "devDependencies": {
25
25
  "vitest": "^2.1.9"