Skip to content

Commit 495d2a0

Browse files
committed
fix(workflow): scope a workflow approval to the plan, not the description
Review findings on the preceding commit. Keying the session approval on the description let a second call keep the description, swap in a different 128-item list, and match the earlier grant — the exact fan-out the preview exists to expose. The subject now covers every field that changes what runs, the item list included. It cannot carry that plan verbatim. The rule DSL parses `Tool(subject)` by splitting on the first paren and then glob-matches the subject, so JSON punctuation breaks parsing and survives neither escaping nor picomatch: an approval would be recorded and then never match again. The subject is a trimmed description plus a digest of the plan, and a new test drives the real `matchPermissionRule` rather than the `matchesRule` callback, which is what made the encoding failure visible in the first place. Also: capture the workflow arguments for `/workflow save` when the tool call completes rather than inside the shared helper the streaming path also calls, so a half-parsed argument set cannot be saved; move the persistence into `writeSavedWorkflowSkill` in agent-core so surfaces other than the TUI can keep a workflow; and encode YAML scalars with `JSON.stringify`, which escapes the newlines and control characters the hand-rolled version emitted raw.
1 parent 4088ef7 commit 495d2a0

8 files changed

Lines changed: 159 additions & 31 deletions

File tree

apps/pythinker-code/src/tui/commands/dynamic-workflow.ts

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
1-
import { mkdir, writeFile } from 'node:fs/promises';
2-
31
import {
4-
renderSavedWorkflowSkill,
5-
savedWorkflowSkillDir,
62
savedWorkflowSkillName,
3+
writeSavedWorkflowSkill,
74
type PermissionMode,
85
} from '@pythoughts/pythinker-code-sdk';
9-
import { join } from 'pathe';
106

117
import { getDataDir } from '#/utils/paths';
128
import {
@@ -153,26 +149,20 @@ async function handleSaveSubcommand(host: SlashCommandHost, input: string): Prom
153149
}
154150

155151
try {
156-
const dir = savedWorkflowSkillDir({
152+
const dir = await writeSavedWorkflowSkill({
157153
scope: 'project',
158-
name,
159154
projectRoot: host.state.appState.workDir,
160155
brandHomeDir: getDataDir(),
161-
});
162-
await mkdir(dir, { recursive: true });
163-
await writeFile(
164-
join(dir, 'SKILL.md'),
165-
renderSavedWorkflowSkill({
166-
name: savedWorkflowSkillName(name),
156+
workflow: {
157+
name,
167158
description,
168159
subagentType: stringArg(args, 'subagent_type'),
169160
promptTemplate: stringArg(args, 'prompt_template'),
170161
model: stringArg(args, 'model'),
171162
effort: stringArg(args, 'effort'),
172163
outputSchema: recordArg(args, 'output_schema'),
173-
}),
174-
'utf8',
175-
);
164+
},
165+
});
176166
host.refreshSlashCommandAutocomplete();
177167
host.showStatus(`Saved /${savedWorkflowSkillName(name)} to ${dir}.`);
178168
} catch (error) {

apps/pythinker-code/src/tui/controllers/subagent-event-handler.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,10 @@ export class SubAgentEventHandler {
275275
if (this.isRetiredDynamicWorkflowToolCall(toolCallId)) return;
276276
const missionControl = this.ensureDynamicWorkflowMissionControl(toolCallId, args);
277277
missionControl.markInputComplete();
278+
// Captured here rather than in `ensure…`, which the delta path also calls:
279+
// mid-stream arguments are half-parsed, and saving those would write a
280+
// workflow missing most of its items.
281+
this.host.state.lastDynamicWorkflowArgs = args;
278282
this.requestRender();
279283
}
280284

@@ -633,8 +637,6 @@ export class SubAgentEventHandler {
633637
args: Record<string, unknown>,
634638
options: { readonly streamingArguments?: string } = {},
635639
): DynamicWorkflowMissionControlComponent {
636-
// Kept so `/workflow save` can name a run the user just watched succeed.
637-
this.host.state.lastDynamicWorkflowArgs = args;
638640
const existing = this.dynamicWorkflowMissionControls.get(toolCallId);
639641
if (existing !== undefined) {
640642
existing.updateArgs(args, options);

apps/pythinker-code/test/tui/commands/dynamic-workflow.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,7 @@ describe('/workflow save', () => {
468468
expect.stringContaining('not a valid skill name'),
469469
);
470470
expect(host.refreshSlashCommandAutocomplete).not.toHaveBeenCalled();
471-
await expect(fs.stat(join(workDir, '.pythinker-code'))).rejects.toThrow(/ENOENT/);
471+
await expect(fs.stat(join(workDir, '.pythinker-code'))).rejects.toThrow(/ENOENT/u);
472472
} finally {
473473
await fs.rm(workDir, { recursive: true, force: true });
474474
}

packages/agent-core/src/agent/dynamic-workflow/save-as-skill.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { promises as fs } from 'node:fs';
2+
13
import path from 'pathe';
24

35
import { normalizeSkillName } from '../../skill/types';
@@ -62,8 +64,14 @@ export function savedWorkflowSkillDir(input: {
6264
return path.join(input.brandHomeDir, 'skills', normalized);
6365
}
6466

67+
/**
68+
* YAML's double-quoted style uses JSON's escapes, so `JSON.stringify` produces
69+
* a valid scalar and handles what hand-rolled quote/backslash escaping misses:
70+
* a newline or control character in a description would otherwise be emitted
71+
* raw and split the frontmatter.
72+
*/
6573
function quoteYamlScalar(value: string): string {
66-
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
74+
return JSON.stringify(value);
6775
}
6876

6977
// A fence that cannot collide with the template body: start at ``` and grow
@@ -108,3 +116,31 @@ export function renderSavedWorkflowSkill(workflow: SavedWorkflow): string {
108116
}
109117
return `${lines.join('\n')}\n`;
110118
}
119+
120+
/**
121+
* Write a saved workflow to disk and return the directory it landed in.
122+
*
123+
* Lives here rather than in the slash command so every surface that can run a
124+
* workflow can also keep one. The name is validated before any directory is
125+
* created, so a rejected name leaves nothing behind.
126+
*/
127+
export async function writeSavedWorkflowSkill(input: {
128+
readonly scope: SavedWorkflowScope;
129+
readonly workflow: SavedWorkflow;
130+
readonly projectRoot: string;
131+
readonly brandHomeDir: string;
132+
}): Promise<string> {
133+
const dir = savedWorkflowSkillDir({
134+
scope: input.scope,
135+
name: input.workflow.name,
136+
projectRoot: input.projectRoot,
137+
brandHomeDir: input.brandHomeDir,
138+
});
139+
const content = renderSavedWorkflowSkill({
140+
...input.workflow,
141+
name: savedWorkflowSkillName(input.workflow.name),
142+
});
143+
await fs.mkdir(dir, { recursive: true });
144+
await fs.writeFile(path.join(dir, 'SKILL.md'), content, 'utf8');
145+
return dir;
146+
}

packages/agent-core/src/agent/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export {
7070
renderSavedWorkflowSkill,
7171
savedWorkflowSkillDir,
7272
savedWorkflowSkillName,
73+
writeSavedWorkflowSkill,
7374
} from './dynamic-workflow/save-as-skill';
7475
export type { SavedWorkflow, SavedWorkflowScope } from './dynamic-workflow/save-as-skill';
7576
export type { BuiltinTool, ToolInfo, ToolSource, UserToolRegistration } from './tool';

packages/agent-core/src/tools/builtin/collaboration/dynamic-workflow.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { createHash } from 'node:crypto';
2+
13
import { z } from 'zod';
24

35
import type { WorkflowWarningEvent } from '@pythoughts/protocol';
@@ -163,6 +165,7 @@ export class DynamicWorkflowTool implements BuiltinTool<DynamicWorkflowToolInput
163165

164166
resolveExecution(args: DynamicWorkflowToolInput): ToolExecution {
165167
const workflow = dynamicWorkflowPreview(args);
168+
const approvalSubject = dynamicWorkflowApprovalSubject(args, workflow);
166169
return {
167170
accesses: ToolAccesses.all(),
168171
description: `Launching Dynamic Workflow: ${args.description}`,
@@ -172,13 +175,16 @@ export class DynamicWorkflowTool implements BuiltinTool<DynamicWorkflowToolInput
172175
prompt: args.description,
173176
workflow,
174177
},
175-
// Keyed on the description so "approve for this session" grants this one
176-
// workflow, not every DynamicWorkflow call for the rest of the session.
177-
// The matcher has to come with it: an arg-bearing rule with no
178+
// Keyed on the whole plan, not the tool name and not the description.
179+
// The bare name granted every future DynamicWorkflow call; the
180+
// description alone would let a second call reuse it and swap in 128
181+
// different items, which is exactly the fan-out the preview exists to
182+
// show. "Approve for this session" now grants the plan that was shown.
183+
// The matcher has to ship with it: an arg-bearing rule with no
178184
// `matchesRule` never matches, so the grant would be recorded and then
179185
// silently ignored on every later call.
180-
approvalRule: literalRulePattern(this.name, args.description),
181-
matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.description),
186+
approvalRule: literalRulePattern(this.name, approvalSubject),
187+
matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, approvalSubject),
182188
execute: (ctx) => this.execution(args, ctx),
183189
};
184190
}
@@ -375,6 +381,48 @@ function dynamicWorkflowPreview(args: DynamicWorkflowToolInput): {
375381
};
376382
}
377383

384+
/**
385+
* Characters a permission-rule subject can carry safely. The rule DSL parses
386+
* `Tool(subject)` by splitting on the first paren, and the subject is then glob
387+
* matched — so parens break parsing outright and `{}[]*?!+@|` survive neither
388+
* escaping nor picomatch reliably. Everything else is dropped from the readable
389+
* half of the subject; the digest carries the precision.
390+
*/
391+
const RULE_SUBJECT_UNSAFE = /[^a-zA-Z0-9 ._-]/gu;
392+
393+
/**
394+
* Subject a session approval is recorded against: a readable prefix plus a
395+
* digest of the whole plan.
396+
*
397+
* Every field that changes what actually runs feeds the digest, the item list
398+
* included. Keying on the description alone would let a later call keep the
399+
* description, swap in a different 128-item list, and ride in on the earlier
400+
* grant — the precise fan-out the preview exists to expose. Two plans share a
401+
* grant only when they would launch the same work.
402+
*
403+
* The plan cannot be the subject verbatim: a JSON blob does not survive the
404+
* rule DSL. Hence digest, with a trimmed description kept in front so a
405+
* recorded rule is still recognisable.
406+
*/
407+
function dynamicWorkflowApprovalSubject(
408+
args: DynamicWorkflowToolInput,
409+
workflow: { readonly items: readonly string[]; readonly agent_count: number },
410+
): string {
411+
const plan = JSON.stringify({
412+
description: args.description,
413+
subagentType: normalizeOptionalString(args.subagent_type),
414+
promptTemplate: normalizeOptionalString(args.prompt_template),
415+
model: normalizeOptionalString(args.model),
416+
effort: normalizeOptionalString(args.effort),
417+
outputSchema: args.output_schema,
418+
agentCount: workflow.agent_count,
419+
items: workflow.items,
420+
});
421+
const digest = createHash('sha256').update(plan).digest('hex').slice(0, 16);
422+
const label = args.description.replace(RULE_SUBJECT_UNSAFE, ' ').trim();
423+
return label.length === 0 ? digest : `${label} ${digest}`;
424+
}
425+
378426
function renderItemPrompt(item: string, promptTemplate: string | undefined): string {
379427
return promptTemplate === undefined
380428
? item

packages/agent-core/test/tools/builtin-current.test.ts

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from '../../src/agent/dynamic-workflow/run-id';
2020
import { resolveWorkflowSizeGuideline } from '../../src/agent/dynamic-workflow/size-guideline';
2121
import { FLAG_DEFINITIONS, FlagResolver } from '../../src/flags';
22+
import { matchPermissionRule } from '../../src/agent/permission/matches-rule';
2223
import { estimateTokens } from '../../src/utils/tokens';
2324
import type {
2425
QueuedSubagentRunResult,
@@ -857,18 +858,67 @@ describe('current builtin collaboration tools', () => {
857858
// fan-out. The matcher must ship with the keyed rule: an arg-bearing rule with
858859
// no `matchesRule` never matches, which would record the grant and then ignore
859860
// it on every later call.
860-
it('DynamicWorkflow keys its approval rule on the description and can match it', () => {
861+
it('DynamicWorkflow matches a session approval only for the identical plan', () => {
861862
const tool = new DynamicWorkflowTool(mockSubagentHost({}), mockDynamicWorkflowMode());
862-
const execution = tool.resolveExecution({
863+
const subjectOf = (input: Parameters<DynamicWorkflowTool['resolveExecution']>[0]): string => {
864+
const execution = tool.resolveExecution(input);
865+
if (execution.isError === true) throw new Error('resolveExecution returned an error');
866+
// `Tool(subject)` — recover the subject the approval was recorded against.
867+
return execution.approvalRule.slice('DynamicWorkflow('.length, -1);
868+
};
869+
870+
const base = {
863871
description: 'Review files',
864872
prompt_template: 'Review {{item}}',
865873
items: ['src/a.ts', 'src/b.ts'],
874+
};
875+
const execution = tool.resolveExecution(base);
876+
if (execution.isError === true) throw new Error('resolveExecution returned an error');
877+
878+
expect(execution.matchesRule?.(subjectOf(base))).toBe(true);
879+
880+
// The description is the obvious key and the wrong one: reusing it while
881+
// swapping the item list is exactly how an unreviewed fan-out would ride in
882+
// on an earlier approval.
883+
expect(
884+
execution.matchesRule?.(
885+
subjectOf({ ...base, items: Array.from({ length: 128 }, (_, i) => `src/${String(i)}.ts`) }),
886+
),
887+
).toBe(false);
888+
expect(execution.matchesRule?.(subjectOf({ ...base, model: 'other-model' }))).toBe(false);
889+
expect(execution.matchesRule?.(subjectOf({ ...base, subagent_type: 'shell' }))).toBe(false);
890+
expect(
891+
execution.matchesRule?.(subjectOf({ ...base, prompt_template: 'Rewrite {{item}}' })),
892+
).toBe(false);
893+
});
894+
895+
// `matchesRule` alone is not proof: the recorded rule is `Tool(subject)`,
896+
// which is parsed by splitting on the first paren and then glob matched. A
897+
// subject carrying JSON punctuation fails both, so an approval would be
898+
// stored and then never match again. Drive the real matcher, not the callback.
899+
it('DynamicWorkflow session approval survives the permission rule DSL', () => {
900+
const tool = new DynamicWorkflowTool(mockSubagentHost({}), mockDynamicWorkflowMode());
901+
const args = {
902+
// Punctuation the DSL cannot carry, plus a glob character.
903+
description: 'Review (all) files: *.ts {urgent}',
904+
prompt_template: 'Review {{item}}',
905+
items: ['src/a.ts', 'src/b.ts'],
906+
};
907+
const execution = tool.resolveExecution(args);
908+
if (execution.isError === true) throw new Error('resolveExecution returned an error');
909+
910+
const match = matchPermissionRule({
911+
rule: {
912+
decision: 'allow',
913+
scope: 'session-runtime',
914+
pattern: execution.approvalRule,
915+
reason: 'approve for session',
916+
},
917+
toolName: 'DynamicWorkflow',
918+
execution,
866919
});
867-
if (execution.isError === true) throw new Error('DynamicWorkflow resolveExecution returned an error');
868920

869-
expect(execution.approvalRule).toBe('DynamicWorkflow(Review files)');
870-
expect(execution.matchesRule?.('Review files')).toBe(true);
871-
expect(execution.matchesRule?.('Delete everything')).toBe(false);
921+
expect(match).toMatchObject({ strategy: 'matches_rule', hasRuleArgs: true });
872922
});
873923

874924
it('DynamicWorkflow previews the fan-out for the approval panel', () => {

packages/node-sdk/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export {
4949
renderSavedWorkflowSkill,
5050
savedWorkflowSkillDir,
5151
savedWorkflowSkillName,
52+
writeSavedWorkflowSkill,
5253
} from '@pythoughts/agent-core';
5354
export type { SavedWorkflow, SavedWorkflowScope } from '@pythoughts/agent-core';
5455
export { buildSkillSlashCommands, isUserActivatableSkill } from '#/skill-commands';

0 commit comments

Comments
 (0)