@cleocode/playbooks 2026.6.12 → 2026.6.14
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/index.d.ts +1 -1
- package/dist/index.js +9 -2
- package/dist/parser.js +114 -1
- package/dist/runtime.d.ts +92 -34
- package/dist/runtime.js +257 -149
- package/package.json +3 -3
- package/src/__tests__/fixtures/conditional-branch.cantbook +50 -0
- package/src/__tests__/fixtures/pi-skill-only.cantbook +26 -0
- package/src/__tests__/guarded-deterministic-dispatch.test.ts +225 -0
- package/src/__tests__/inputs-bindings.test.ts +250 -0
- package/src/__tests__/node-allowlist.test.ts +186 -0
- package/src/__tests__/node-profile.test.ts +142 -0
- package/src/__tests__/parser.test.ts +191 -0
- package/src/__tests__/pi-cantbook-e2e.test.ts +294 -0
- package/src/__tests__/rcasd-one-envelope.test.ts +239 -0
- package/src/__tests__/runtime.test.ts +502 -4
- package/src/index.ts +8 -3
- package/src/parser.ts +144 -1
- package/src/runtime.ts +363 -198
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T11802 (M4 cantbook done-gate) — every `deterministic` (shell) node dispatch in
|
|
3
|
+
* `executePlaybook` routes through the `guard.ts` deny-first chokepoint.
|
|
4
|
+
*
|
|
5
|
+
* ## What this proves
|
|
6
|
+
*
|
|
7
|
+
* - **AC1 / AC2** — a minimal `.cantbook` with a `deterministic` shell step,
|
|
8
|
+
* executed through the REAL {@link executePlaybook} runtime with the
|
|
9
|
+
* production-shaped {@link createGuardedDeterministicRunner} wired to a real
|
|
10
|
+
* {@link createToolGuard}, reaches the guard: the spawned command actually runs
|
|
11
|
+
* THROUGH `guard.executeShell` (proven by captured stdout in the merged run
|
|
12
|
+
* context). There is NO `child_process` import in the runtime call graph — the
|
|
13
|
+
* guard owns the spawn.
|
|
14
|
+
* - **AC3** — when the SAME runtime dispatches a `deterministic` node whose
|
|
15
|
+
* command is on the guard's denylist (`enforce` mode), the guard REJECTS it
|
|
16
|
+
* BEFORE any process is spawned; the node terminates as a contained failure
|
|
17
|
+
* (the runtime contract is non-throwing) carrying the guard's
|
|
18
|
+
* `E_TOOL_GUARD_DENIED` message.
|
|
19
|
+
* - **AC5** — no new state machine: the runtime is unchanged; only the injected
|
|
20
|
+
* `deterministicRunner` is the guard-routed one.
|
|
21
|
+
*
|
|
22
|
+
* ## Determinism + isolation
|
|
23
|
+
*
|
|
24
|
+
* - IN-PROCESS only — vitest source, NO subprocess driver (tsx unresolvable in CI).
|
|
25
|
+
* - daemon-OFF — an in-memory `node:sqlite` DB, no daemon.
|
|
26
|
+
* - The allowed command is `node -e <print>` (always present on the test box, no
|
|
27
|
+
* network, no filesystem mutation) so the "guard actually spawned" assertion is
|
|
28
|
+
* portable and side-effect-free.
|
|
29
|
+
* - A temp project root scopes the guard's `allowedRoots`; `cwd` is pinned to it.
|
|
30
|
+
*
|
|
31
|
+
* @epic T11391
|
|
32
|
+
* @task T11802
|
|
33
|
+
* @saga T11387
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
37
|
+
import { createRequire } from 'node:module';
|
|
38
|
+
import { tmpdir } from 'node:os';
|
|
39
|
+
import { dirname, join, resolve } from 'node:path';
|
|
40
|
+
import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
|
|
41
|
+
import { fileURLToPath } from 'node:url';
|
|
42
|
+
import type { PlaybookDefinition, PlaybookDeterministicNode } from '@cleocode/contracts';
|
|
43
|
+
import {
|
|
44
|
+
createGuardedDeterministicRunner,
|
|
45
|
+
createToolGuard,
|
|
46
|
+
DEFAULT_DETERMINISTIC_DENIED_COMMANDS,
|
|
47
|
+
} from '@cleocode/core/internal';
|
|
48
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
49
|
+
import { type DeterministicRunner, executePlaybook } from '../runtime.js';
|
|
50
|
+
|
|
51
|
+
const _require = createRequire(import.meta.url);
|
|
52
|
+
type DatabaseSync = _DatabaseSyncType;
|
|
53
|
+
const { DatabaseSync } = _require('node:sqlite') as {
|
|
54
|
+
DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
58
|
+
|
|
59
|
+
/** Absolute path to the T889 playbook-tables migration SQL. */
|
|
60
|
+
const MIGRATION_SQL = resolve(
|
|
61
|
+
__dirname,
|
|
62
|
+
'../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
/** Apply a multi-statement Drizzle migration (split on the breakpoint token). */
|
|
66
|
+
function applyMigration(db: DatabaseSync, sql: string): void {
|
|
67
|
+
const statements = sql
|
|
68
|
+
.split(/--> statement-breakpoint/)
|
|
69
|
+
.map((s) => s.trim())
|
|
70
|
+
.filter((s) => s.length > 0);
|
|
71
|
+
for (const stmt of statements) {
|
|
72
|
+
const lines = stmt.split('\n');
|
|
73
|
+
const hasSql = lines.some((l) => l.trim().length > 0 && !l.trim().startsWith('--'));
|
|
74
|
+
if (hasSql) db.exec(stmt);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Build a single-node `deterministic` playbook (the entry node is terminal). */
|
|
79
|
+
function singleDeterministicPlaybook(node: PlaybookDeterministicNode): PlaybookDefinition {
|
|
80
|
+
return { version: '1.0', name: 'guarded-shell', nodes: [node], edges: [] };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('T11802 (M4): deterministic node dispatch routes through guard.ts', () => {
|
|
84
|
+
let db: DatabaseSync;
|
|
85
|
+
let projectRoot: string;
|
|
86
|
+
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
db = new DatabaseSync(':memory:');
|
|
89
|
+
db.exec('PRAGMA foreign_keys=ON');
|
|
90
|
+
applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
|
|
91
|
+
projectRoot = mkdtempSync(join(tmpdir(), 't11802-guarded-shell-'));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
db.close();
|
|
96
|
+
rmSync(projectRoot, { recursive: true, force: true });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build the production-shaped deterministic runner: the SAME guard the CLI's
|
|
101
|
+
* `buildDefaultDeterministicRunner` constructs (allowedRoots + denylist +
|
|
102
|
+
* enforce), wrapped by `createGuardedDeterministicRunner`.
|
|
103
|
+
*/
|
|
104
|
+
function buildGuardedRunner(): DeterministicRunner {
|
|
105
|
+
const tools = createToolGuard({
|
|
106
|
+
allowedRoots: [projectRoot],
|
|
107
|
+
deniedCommands: DEFAULT_DETERMINISTIC_DENIED_COMMANDS,
|
|
108
|
+
mode: 'enforce',
|
|
109
|
+
});
|
|
110
|
+
// Structurally assignable to the runtime's DeterministicRunner.
|
|
111
|
+
return createGuardedDeterministicRunner({ tools });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
it('AC2: a shell step is intercepted by the guard — the command runs THROUGH guard.executeShell', async () => {
|
|
115
|
+
// `node -e` prints a sentinel; capturing it proves the guard actually
|
|
116
|
+
// spawned the process (not a mock).
|
|
117
|
+
const SENTINEL = 'guard-intercepted-T11802';
|
|
118
|
+
const node: PlaybookDeterministicNode = {
|
|
119
|
+
id: 'shell',
|
|
120
|
+
type: 'deterministic',
|
|
121
|
+
command: process.execPath, // node — always present, never on the denylist
|
|
122
|
+
args: ['-e', `process.stdout.write(${JSON.stringify(SENTINEL)})`],
|
|
123
|
+
cwd: projectRoot,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const result = await executePlaybook({
|
|
127
|
+
db,
|
|
128
|
+
playbook: singleDeterministicPlaybook(node),
|
|
129
|
+
playbookHash: 'hash-allowed',
|
|
130
|
+
initialContext: { taskId: 'T11802' },
|
|
131
|
+
dispatcher: {
|
|
132
|
+
// The agentic dispatcher must NEVER be reached for a deterministic node.
|
|
133
|
+
async dispatch() {
|
|
134
|
+
return {
|
|
135
|
+
status: 'failure',
|
|
136
|
+
output: {},
|
|
137
|
+
error: 'agentic dispatcher reached for a deterministic node — guard bypassed!',
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
deterministicRunner: buildGuardedRunner(),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(result.terminalStatus).toBe('completed');
|
|
145
|
+
// The guard's executeShell captured the child's stdout into the merged
|
|
146
|
+
// context — dispositive that the command was spawned THROUGH the guard.
|
|
147
|
+
expect(result.finalContext['shell_stdout']).toBe(SENTINEL);
|
|
148
|
+
expect(result.finalContext['shell_exitCode']).toBe(0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('AC3: a denied command is rejected by the guard denylist when dispatched from executePlaybook', async () => {
|
|
152
|
+
// `rm` is on DEFAULT_DETERMINISTIC_DENIED_COMMANDS — the guard rejects it
|
|
153
|
+
// BEFORE any process is spawned (enforce mode).
|
|
154
|
+
expect(DEFAULT_DETERMINISTIC_DENIED_COMMANDS).toContain('rm');
|
|
155
|
+
|
|
156
|
+
const node: PlaybookDeterministicNode = {
|
|
157
|
+
id: 'danger',
|
|
158
|
+
type: 'deterministic',
|
|
159
|
+
command: 'rm',
|
|
160
|
+
args: ['-rf', join(projectRoot, 'anything')],
|
|
161
|
+
cwd: projectRoot,
|
|
162
|
+
// One attempt → first denial is terminal (no retry storms).
|
|
163
|
+
on_failure: { max_iterations: 1 },
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const result = await executePlaybook({
|
|
167
|
+
db,
|
|
168
|
+
playbook: singleDeterministicPlaybook(node),
|
|
169
|
+
playbookHash: 'hash-denied',
|
|
170
|
+
initialContext: { taskId: 'T11802' },
|
|
171
|
+
dispatcher: {
|
|
172
|
+
async dispatch() {
|
|
173
|
+
return {
|
|
174
|
+
status: 'failure',
|
|
175
|
+
output: {},
|
|
176
|
+
error: 'agentic dispatcher reached for a deterministic node — guard bypassed!',
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
deterministicRunner: buildGuardedRunner(),
|
|
181
|
+
maxIterationsDefault: 1,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// The denial is a contained node failure (the runtime never throws).
|
|
185
|
+
expect(result.terminalStatus).toBe('exceeded_iteration_cap');
|
|
186
|
+
expect(result.exceededNodeId).toBe('danger');
|
|
187
|
+
// The guard's enforce-mode rejection message reached the runtime.
|
|
188
|
+
expect(result.errorContext ?? '').toMatch(/denylist|E_TOOL_GUARD_DENIED|"rm"/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('AC1: the guard is the ONLY spawn path — a denied command never spawns rm', async () => {
|
|
192
|
+
// Targeting a path INSIDE the project root so, IF the guard were bypassed,
|
|
193
|
+
// rm would actually delete it. The guard must stop it first. We assert the
|
|
194
|
+
// failure shape (no rm executed) rather than filesystem side effects so the
|
|
195
|
+
// test is hermetic even on a box without `rm`.
|
|
196
|
+
const node: PlaybookDeterministicNode = {
|
|
197
|
+
id: 'danger',
|
|
198
|
+
type: 'deterministic',
|
|
199
|
+
command: '/bin/rm', // absolute path — basename `rm` is still denied
|
|
200
|
+
args: ['-f', join(projectRoot, 'sentinel')],
|
|
201
|
+
cwd: projectRoot,
|
|
202
|
+
on_failure: { max_iterations: 1 },
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
let agenticReached = false;
|
|
206
|
+
const result = await executePlaybook({
|
|
207
|
+
db,
|
|
208
|
+
playbook: singleDeterministicPlaybook(node),
|
|
209
|
+
playbookHash: 'hash-abs-denied',
|
|
210
|
+
initialContext: { taskId: 'T11802' },
|
|
211
|
+
dispatcher: {
|
|
212
|
+
async dispatch() {
|
|
213
|
+
agenticReached = true;
|
|
214
|
+
return { status: 'success', output: { bypassed: true } };
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
deterministicRunner: buildGuardedRunner(),
|
|
218
|
+
maxIterationsDefault: 1,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(agenticReached).toBe(false); // never fell back to the agentic path
|
|
222
|
+
expect(result.terminalStatus).toBe('exceeded_iteration_cap');
|
|
223
|
+
expect(result.errorContext ?? '').toMatch(/denylist|E_TOOL_GUARD_DENIED|"rm"/);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T1944 (M4 cantbook done-gate) — `PlaybookAgenticNode.inputs` →
|
|
3
|
+
* `AgentDispatchInput.bindings` wiring + mustache resolution at the dispatch
|
|
4
|
+
* boundary.
|
|
5
|
+
*
|
|
6
|
+
* Two layers of coverage:
|
|
7
|
+
*
|
|
8
|
+
* 1. Pure-unit tests of {@link resolveStepBindings} — precedence, shadowing,
|
|
9
|
+
* dot-path resolution, and the lenient missing-key rule, exercised in
|
|
10
|
+
* isolation with no DB.
|
|
11
|
+
* 2. In-process `executePlaybook` end-to-end — a `.cantbook`-equivalent node
|
|
12
|
+
* with `inputs: { topic: '{{inputs.epicId}}' }` resolves and the bound
|
|
13
|
+
* value reaches {@link AgentDispatchInput.bindings} at dispatch time.
|
|
14
|
+
*
|
|
15
|
+
* No `@cleocode/*` module is mocked — the resolver is the real canonical
|
|
16
|
+
* T1238 engine and the runtime runs against an in-memory `node:sqlite` DB.
|
|
17
|
+
*
|
|
18
|
+
* @task T1944 — wire PlaybookAgenticNode.inputs → AgentDispatchInput.bindings
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import { dirname, resolve } from 'node:path';
|
|
24
|
+
import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import type { PlaybookAgenticNode, PlaybookDefinition } from '@cleocode/contracts';
|
|
27
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
28
|
+
import {
|
|
29
|
+
type AgentDispatcher,
|
|
30
|
+
type AgentDispatchInput,
|
|
31
|
+
type AgentDispatchResult,
|
|
32
|
+
executePlaybook,
|
|
33
|
+
resolveStepBindings,
|
|
34
|
+
} from '../runtime.js';
|
|
35
|
+
|
|
36
|
+
const _require = createRequire(import.meta.url);
|
|
37
|
+
type DatabaseSync = _DatabaseSyncType;
|
|
38
|
+
const { DatabaseSync } = _require('node:sqlite') as {
|
|
39
|
+
DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
const MIGRATION_SQL = resolve(
|
|
44
|
+
__dirname,
|
|
45
|
+
'../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
function applyMigration(db: DatabaseSync, sql: string): void {
|
|
49
|
+
const statements = sql
|
|
50
|
+
.split(/--> statement-breakpoint/)
|
|
51
|
+
.map((s) => s.trim())
|
|
52
|
+
.filter((s) => s.length > 0);
|
|
53
|
+
for (const stmt of statements) {
|
|
54
|
+
const lines = stmt.split('\n');
|
|
55
|
+
const hasSql = lines.some((l) => l.trim().length > 0 && !l.trim().startsWith('--'));
|
|
56
|
+
if (hasSql) db.exec(stmt);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function agenticNode(
|
|
61
|
+
id: string,
|
|
62
|
+
overrides: Partial<PlaybookAgenticNode> = {},
|
|
63
|
+
): PlaybookAgenticNode {
|
|
64
|
+
return { id, type: 'agentic', skill: `skill-${id}`, ...overrides };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Dispatcher that records the full {@link AgentDispatchInput} per call. */
|
|
68
|
+
function makeBindingRecorder(
|
|
69
|
+
handler: (input: AgentDispatchInput) => AgentDispatchResult,
|
|
70
|
+
): AgentDispatcher & { calls: AgentDispatchInput[] } {
|
|
71
|
+
const calls: AgentDispatchInput[] = [];
|
|
72
|
+
return {
|
|
73
|
+
calls,
|
|
74
|
+
async dispatch(input: AgentDispatchInput): Promise<AgentDispatchResult> {
|
|
75
|
+
// Snapshot bindings/context so later mutation cannot taint the assertion.
|
|
76
|
+
calls.push({
|
|
77
|
+
...input,
|
|
78
|
+
context: { ...input.context },
|
|
79
|
+
bindings: input.bindings === undefined ? undefined : { ...input.bindings },
|
|
80
|
+
});
|
|
81
|
+
return handler(input);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// resolveStepBindings — pure unit
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
describe('T1944: resolveStepBindings (pure)', () => {
|
|
91
|
+
it('AC5: resolves a {{inputs.epicId}} template from the run context', () => {
|
|
92
|
+
const node = agenticNode('research', { inputs: { topic: '{{inputs.epicId}}' } });
|
|
93
|
+
const context = { inputs: { epicId: 'T1942' } };
|
|
94
|
+
|
|
95
|
+
const bound = resolveStepBindings(node, context);
|
|
96
|
+
|
|
97
|
+
expect(bound['topic']).toBe('T1942');
|
|
98
|
+
// Original context keys remain present.
|
|
99
|
+
expect(bound['inputs']).toEqual({ epicId: 'T1942' });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('AC5: resolves a flat {{epicId}} single-segment template', () => {
|
|
103
|
+
const node = agenticNode('research', { inputs: { topic: '{{epicId}}' } });
|
|
104
|
+
const bound = resolveStepBindings(node, { epicId: 'T1942' });
|
|
105
|
+
expect(bound['topic']).toBe('T1942');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('AC6: step bindings shadow playbook bindings for the same key', () => {
|
|
109
|
+
// The run context already carries `topic`; the step input MUST win.
|
|
110
|
+
const node = agenticNode('research', { inputs: { topic: '{{inputs.epicId}}' } });
|
|
111
|
+
const context = { topic: 'stale-context-value', inputs: { epicId: 'T1942' } };
|
|
112
|
+
|
|
113
|
+
const bound = resolveStepBindings(node, context);
|
|
114
|
+
|
|
115
|
+
expect(bound['topic']).toBe('T1942');
|
|
116
|
+
expect(bound['topic']).not.toBe('stale-context-value');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('missing-key rule: unresolved {{path}} is left as a literal placeholder', () => {
|
|
120
|
+
const node = agenticNode('research', { inputs: { topic: '{{inputs.nope}}' } });
|
|
121
|
+
const bound = resolveStepBindings(node, { inputs: { epicId: 'T1942' } });
|
|
122
|
+
expect(bound['topic']).toBe('{{inputs.nope}}');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('resolves dot-path nesting deeper than one level', () => {
|
|
126
|
+
const node = agenticNode('research', { inputs: { fw: '{{project.testing.framework}}' } });
|
|
127
|
+
const bound = resolveStepBindings(node, {
|
|
128
|
+
project: { testing: { framework: 'vitest' } },
|
|
129
|
+
});
|
|
130
|
+
expect(bound['fw']).toBe('vitest');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('passes literal (non-template) input values through unchanged', () => {
|
|
134
|
+
const node = agenticNode('research', { inputs: { mode: 'fast' } });
|
|
135
|
+
const bound = resolveStepBindings(node, { x: 1 });
|
|
136
|
+
expect(bound['mode']).toBe('fast');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('returns a shallow copy of context when the node declares no inputs', () => {
|
|
140
|
+
const node = agenticNode('research');
|
|
141
|
+
const context = { taskId: 'T123', a: 1 };
|
|
142
|
+
const bound = resolveStepBindings(node, context);
|
|
143
|
+
expect(bound).toEqual(context);
|
|
144
|
+
expect(bound).not.toBe(context);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('returns a shallow copy of context for an empty inputs map', () => {
|
|
148
|
+
const node = agenticNode('research', { inputs: {} });
|
|
149
|
+
const context = { taskId: 'T123' };
|
|
150
|
+
expect(resolveStepBindings(node, context)).toEqual(context);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('does not mutate the source context', () => {
|
|
154
|
+
const node = agenticNode('research', { inputs: { topic: '{{epicId}}' } });
|
|
155
|
+
const context = { epicId: 'T1942' };
|
|
156
|
+
resolveStepBindings(node, context);
|
|
157
|
+
expect(context).toEqual({ epicId: 'T1942' });
|
|
158
|
+
expect(context).not.toHaveProperty('topic');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// executePlaybook — bindings reach the dispatch boundary (in-process e2e)
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
describe('T1944: AgentDispatchInput.bindings (in-process)', () => {
|
|
167
|
+
let db: DatabaseSync;
|
|
168
|
+
|
|
169
|
+
beforeEach(() => {
|
|
170
|
+
db = new DatabaseSync(':memory:');
|
|
171
|
+
db.exec('PRAGMA foreign_keys=ON');
|
|
172
|
+
applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
|
|
173
|
+
});
|
|
174
|
+
afterEach(() => db.close());
|
|
175
|
+
|
|
176
|
+
it('AC2: resolved step input reaches AgentDispatchInput.bindings at dispatch', async () => {
|
|
177
|
+
const playbook: PlaybookDefinition = {
|
|
178
|
+
version: '1.0',
|
|
179
|
+
name: 'inputs-bindings',
|
|
180
|
+
nodes: [agenticNode('research', { inputs: { topic: '{{inputs.epicId}}' } })],
|
|
181
|
+
edges: [],
|
|
182
|
+
};
|
|
183
|
+
const dispatcher = makeBindingRecorder(() => ({ status: 'success', output: {} }));
|
|
184
|
+
|
|
185
|
+
const result = await executePlaybook({
|
|
186
|
+
db,
|
|
187
|
+
playbook,
|
|
188
|
+
playbookHash: 'hash-t1944',
|
|
189
|
+
initialContext: { inputs: { epicId: 'T1942' } },
|
|
190
|
+
dispatcher,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(result.terminalStatus).toBe('completed');
|
|
194
|
+
expect(dispatcher.calls).toHaveLength(1);
|
|
195
|
+
// The resolved binding (NOT the raw template) reaches the dispatcher.
|
|
196
|
+
expect(dispatcher.calls[0]?.bindings?.['topic']).toBe('T1942');
|
|
197
|
+
// The raw accumulated context still carries the source object.
|
|
198
|
+
expect(dispatcher.calls[0]?.context['inputs']).toEqual({ epicId: 'T1942' });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('AC2: bindings is always populated even when the node declares no inputs', async () => {
|
|
202
|
+
const playbook: PlaybookDefinition = {
|
|
203
|
+
version: '1.0',
|
|
204
|
+
name: 'no-inputs',
|
|
205
|
+
nodes: [agenticNode('plain')],
|
|
206
|
+
edges: [],
|
|
207
|
+
};
|
|
208
|
+
const dispatcher = makeBindingRecorder(() => ({ status: 'success', output: {} }));
|
|
209
|
+
|
|
210
|
+
await executePlaybook({
|
|
211
|
+
db,
|
|
212
|
+
playbook,
|
|
213
|
+
playbookHash: 'hash-t1944-b',
|
|
214
|
+
initialContext: { taskId: 'T123' },
|
|
215
|
+
dispatcher,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(dispatcher.calls[0]?.bindings).toMatchObject({ taskId: 'T123' });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('AC6: step binding shadows a same-named accumulated context key end-to-end', async () => {
|
|
222
|
+
// Node `a` emits `topic`; node `b` declares an `inputs.topic` template that
|
|
223
|
+
// MUST shadow the inherited context value when dispatched.
|
|
224
|
+
const playbook: PlaybookDefinition = {
|
|
225
|
+
version: '1.0',
|
|
226
|
+
name: 'shadowing',
|
|
227
|
+
nodes: [agenticNode('a'), agenticNode('b', { inputs: { topic: '{{inputs.epicId}}' } })],
|
|
228
|
+
edges: [{ from: 'a', to: 'b' }],
|
|
229
|
+
};
|
|
230
|
+
const dispatcher = makeBindingRecorder((input) =>
|
|
231
|
+
input.nodeId === 'a'
|
|
232
|
+
? { status: 'success', output: { topic: 'from-node-a' } }
|
|
233
|
+
: { status: 'success', output: {} },
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
await executePlaybook({
|
|
237
|
+
db,
|
|
238
|
+
playbook,
|
|
239
|
+
playbookHash: 'hash-t1944-c',
|
|
240
|
+
initialContext: { inputs: { epicId: 'T1942' } },
|
|
241
|
+
dispatcher,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const callB = dispatcher.calls.find((c) => c.nodeId === 'b');
|
|
245
|
+
// Accumulated context still has node-a's value...
|
|
246
|
+
expect(callB?.context['topic']).toBe('from-node-a');
|
|
247
|
+
// ...but the step binding shadows it.
|
|
248
|
+
expect(callB?.bindings?.['topic']).toBe('T1942');
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T1947 (M4 cantbook done-gate, Gap E/F) — `PlaybookAgenticNode.allowed_skills`
|
|
3
|
+
* / `.allowed_tools` are parsed (AC1) and reach the dispatch boundary as
|
|
4
|
+
* `AgentDispatchInput.allowedSkills` / `.allowedTools` (AC5) so the dispatcher
|
|
5
|
+
* can intersect them against the Tier-0/1/2 baseline at spawn time.
|
|
6
|
+
*
|
|
7
|
+
* Two layers:
|
|
8
|
+
* 1. Parser → runtime forwarding: a `.cantbook` node with `allowed_skills:` /
|
|
9
|
+
* `allowed_tools:` is parsed and the lists reach {@link AgentDispatchInput}
|
|
10
|
+
* at dispatch time — asserted with a recorder dispatcher (no live backend).
|
|
11
|
+
* 2. Default (un-restricted) nodes carry NO allowlist — the dispatch path is
|
|
12
|
+
* unchanged (additive).
|
|
13
|
+
*
|
|
14
|
+
* The intersection enforcement itself lives in `@cleocode/core`'s
|
|
15
|
+
* `composeSpawnPayload` and is covered by `spawn.test.ts`; this suite proves the
|
|
16
|
+
* runtime hands the allowlists to the dispatcher so that enforcement can occur.
|
|
17
|
+
* No `@cleocode/*` module is mocked.
|
|
18
|
+
*
|
|
19
|
+
* @task T1947
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import { dirname, resolve } from 'node:path';
|
|
25
|
+
import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
28
|
+
import { parsePlaybook } from '../parser.js';
|
|
29
|
+
import {
|
|
30
|
+
type AgentDispatcher,
|
|
31
|
+
type AgentDispatchInput,
|
|
32
|
+
type AgentDispatchResult,
|
|
33
|
+
executePlaybook,
|
|
34
|
+
} from '../runtime.js';
|
|
35
|
+
|
|
36
|
+
const _require = createRequire(import.meta.url);
|
|
37
|
+
type DatabaseSync = _DatabaseSyncType;
|
|
38
|
+
const { DatabaseSync } = _require('node:sqlite') as {
|
|
39
|
+
DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
const MIGRATION_SQL = resolve(
|
|
44
|
+
__dirname,
|
|
45
|
+
'../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
function applyMigration(db: DatabaseSync, sql: string): void {
|
|
49
|
+
const statements = sql
|
|
50
|
+
.split(/--> statement-breakpoint/)
|
|
51
|
+
.map((s) => s.trim())
|
|
52
|
+
.filter((s) => s.length > 0);
|
|
53
|
+
for (const stmt of statements) {
|
|
54
|
+
const lines = stmt.split('\n');
|
|
55
|
+
const hasSql = lines.some((l) => l.trim().length > 0 && !l.trim().startsWith('--'));
|
|
56
|
+
if (hasSql) db.exec(stmt);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Records the full {@link AgentDispatchInput} per call. */
|
|
61
|
+
function makeRecorder(): AgentDispatcher & { calls: AgentDispatchInput[] } {
|
|
62
|
+
const calls: AgentDispatchInput[] = [];
|
|
63
|
+
return {
|
|
64
|
+
calls,
|
|
65
|
+
async dispatch(input: AgentDispatchInput): Promise<AgentDispatchResult> {
|
|
66
|
+
calls.push({ ...input, context: { ...input.context } });
|
|
67
|
+
return { status: 'success', output: {} };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('T1947: AC1 — parser accepts allowed_skills / allowed_tools', () => {
|
|
73
|
+
it('parses string[] allowlists onto the agentic node', () => {
|
|
74
|
+
const yaml = `
|
|
75
|
+
version: "1.0"
|
|
76
|
+
name: gated-flow
|
|
77
|
+
nodes:
|
|
78
|
+
- id: review
|
|
79
|
+
type: agentic
|
|
80
|
+
skill: ct-validator
|
|
81
|
+
allowed_skills: [ct-validator, ct-cleo]
|
|
82
|
+
allowed_tools: [Read, Grep]
|
|
83
|
+
`;
|
|
84
|
+
const { definition } = parsePlaybook(yaml);
|
|
85
|
+
const node = definition.nodes[0];
|
|
86
|
+
expect(node?.type).toBe('agentic');
|
|
87
|
+
if (node?.type !== 'agentic') throw new Error('expected agentic node');
|
|
88
|
+
expect(node.allowed_skills).toEqual(['ct-validator', 'ct-cleo']);
|
|
89
|
+
expect(node.allowed_tools).toEqual(['Read', 'Grep']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('omits both fields when the node declares neither (additive)', () => {
|
|
93
|
+
const yaml = `
|
|
94
|
+
version: "1.0"
|
|
95
|
+
name: plain-flow
|
|
96
|
+
nodes:
|
|
97
|
+
- id: research
|
|
98
|
+
type: agentic
|
|
99
|
+
skill: ct-research-agent
|
|
100
|
+
`;
|
|
101
|
+
const { definition } = parsePlaybook(yaml);
|
|
102
|
+
const node = definition.nodes[0];
|
|
103
|
+
if (node?.type !== 'agentic') throw new Error('expected agentic node');
|
|
104
|
+
expect(node.allowed_skills).toBeUndefined();
|
|
105
|
+
expect(node.allowed_tools).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('rejects a non-string entry in allowed_skills', () => {
|
|
109
|
+
const yaml = `
|
|
110
|
+
version: "1.0"
|
|
111
|
+
name: bad-flow
|
|
112
|
+
nodes:
|
|
113
|
+
- id: review
|
|
114
|
+
type: agentic
|
|
115
|
+
skill: ct-validator
|
|
116
|
+
allowed_skills: [ct-validator, 42]
|
|
117
|
+
`;
|
|
118
|
+
expect(() => parsePlaybook(yaml)).toThrow(/allowed_skills/);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('T1947: AC5 — allowlists reach the dispatch boundary', () => {
|
|
123
|
+
let db: DatabaseSync;
|
|
124
|
+
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
db = new DatabaseSync(':memory:');
|
|
127
|
+
db.exec('PRAGMA foreign_keys=ON');
|
|
128
|
+
applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
|
|
129
|
+
});
|
|
130
|
+
afterEach(() => db.close());
|
|
131
|
+
|
|
132
|
+
it('forwards allowed_skills/allowed_tools as allowedSkills/allowedTools', async () => {
|
|
133
|
+
const yaml = `
|
|
134
|
+
version: "1.0"
|
|
135
|
+
name: gated-flow
|
|
136
|
+
nodes:
|
|
137
|
+
- id: review
|
|
138
|
+
type: agentic
|
|
139
|
+
skill: ct-validator
|
|
140
|
+
allowed_skills: [ct-validator]
|
|
141
|
+
allowed_tools: [Read, Grep]
|
|
142
|
+
`;
|
|
143
|
+
const { definition } = parsePlaybook(yaml);
|
|
144
|
+
const dispatcher = makeRecorder();
|
|
145
|
+
|
|
146
|
+
const result = await executePlaybook({
|
|
147
|
+
db,
|
|
148
|
+
playbook: definition,
|
|
149
|
+
playbookHash: 'hash-t1947',
|
|
150
|
+
initialContext: { taskId: 'T1947' },
|
|
151
|
+
dispatcher,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(result.terminalStatus).toBe('completed');
|
|
155
|
+
expect(dispatcher.calls).toHaveLength(1);
|
|
156
|
+
const call = dispatcher.calls[0];
|
|
157
|
+
expect(call?.nodeId).toBe('review');
|
|
158
|
+
expect(call?.allowedSkills).toEqual(['ct-validator']);
|
|
159
|
+
expect(call?.allowedTools).toEqual(['Read', 'Grep']);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('an un-restricted stage carries NO allowedSkills/allowedTools (additive)', async () => {
|
|
163
|
+
const yaml = `
|
|
164
|
+
version: "1.0"
|
|
165
|
+
name: plain-flow
|
|
166
|
+
nodes:
|
|
167
|
+
- id: research
|
|
168
|
+
type: agentic
|
|
169
|
+
skill: ct-research-agent
|
|
170
|
+
`;
|
|
171
|
+
const { definition } = parsePlaybook(yaml);
|
|
172
|
+
const dispatcher = makeRecorder();
|
|
173
|
+
|
|
174
|
+
await executePlaybook({
|
|
175
|
+
db,
|
|
176
|
+
playbook: definition,
|
|
177
|
+
playbookHash: 'hash-t1947-b',
|
|
178
|
+
initialContext: { taskId: 'T123' },
|
|
179
|
+
dispatcher,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const call = dispatcher.calls[0];
|
|
183
|
+
expect(call?.allowedSkills).toBeUndefined();
|
|
184
|
+
expect(call?.allowedTools).toBeUndefined();
|
|
185
|
+
});
|
|
186
|
+
});
|