@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.
- package/dist/features/workflow-cli/entrypoint/workflow-cli.js +1 -1
- package/dist/features/workflow-runner/entrypoint/workflow-runner.js +8 -2
- package/dist/platform/domain/pre-tool-use-handler.d.ts +3 -1
- package/dist/platform/domain/pre-tool-use-handler.js +62 -9
- package/dist/platform/domain/pre-tool-use-handler.spec.d.ts +1 -0
- package/dist/platform/domain/pre-tool-use-handler.spec.js +80 -0
- package/dist/platform/infra/external-clients/claude-hooks/hook-schemas.js +8 -8
- package/package.json +4 -4
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
25
|
-
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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.
|
|
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-
|
|
21
|
-
"@nt-ai-lab/deterministic-agent-workflow-
|
|
22
|
-
"@nt-ai-lab/deterministic-agent-workflow-event-store": "0.4.
|
|
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"
|