@wichayutdew/pi-workflows 0.3.0 → 1.0.0
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/README.md +53 -39
- package/dist/index.js +5754 -4794
- package/package.json +1 -1
- package/src/command-names.ts +7 -1
- package/src/commands.ts +126 -52
- package/src/config/catalog.ts +162 -0
- package/src/config/ceiling.ts +116 -116
- package/src/config/command-conflicts.ts +4 -4
- package/src/config/diagnostics.ts +28 -0
- package/src/config/load-settings.ts +37 -0
- package/src/config/load-types.ts +53 -0
- package/src/config/load-workflows.ts +135 -0
- package/src/config/load.ts +43 -311
- package/src/config/types.ts +137 -135
- package/src/config/validate.ts +12 -1261
- package/src/config/validation/permissions.ts +291 -0
- package/src/config/validation/prompt.ts +19 -0
- package/src/config/validation/settings.ts +122 -0
- package/src/config/validation/shared.ts +129 -0
- package/src/config/validation/shortcut.ts +118 -0
- package/src/config/validation/step.ts +228 -0
- package/src/config/validation/subagent.ts +288 -0
- package/src/config/validation/workflow.ts +156 -0
- package/src/config/yaml.ts +29 -0
- package/src/digest.ts +38 -6
- package/src/engine/checkpoint.ts +13 -9
- package/src/engine/create-run.ts +42 -0
- package/src/engine/gate-transitions.ts +184 -0
- package/src/engine/reconciliation-history.ts +88 -0
- package/src/engine/resume.ts +19 -7
- package/src/engine/run-advance.ts +122 -0
- package/src/engine/run-lifecycle.ts +124 -0
- package/src/engine/run-reconciliation.ts +116 -0
- package/src/engine/run-validation.ts +128 -0
- package/src/engine/state-types.ts +64 -0
- package/src/engine/state.ts +10 -193
- package/src/engine/transition-helpers.ts +28 -0
- package/src/engine/transition-types.ts +8 -0
- package/src/engine/transitions.ts +17 -471
- package/src/harness/action-context.ts +181 -0
- package/src/harness/catalog.ts +45 -0
- package/src/harness/context-idle.ts +21 -0
- package/src/harness/core-actions.ts +299 -0
- package/src/harness/delegation-control-actions.ts +250 -0
- package/src/harness/delegation-failure.ts +215 -0
- package/src/harness/delegation-plan.ts +219 -0
- package/src/harness/delegation-recovery-validation.ts +159 -0
- package/src/harness/delegation-response-actions.ts +295 -0
- package/src/harness/delegation-retry-policy.ts +118 -0
- package/src/harness/dependencies.ts +165 -0
- package/src/harness/gate-submission-action.ts +154 -0
- package/src/harness/lifecycle-actions.ts +154 -0
- package/src/harness/pause-actions.ts +120 -0
- package/src/harness/plannotator-result-actions.ts +123 -0
- package/src/harness/prompt-gate-actions.ts +270 -0
- package/src/harness/resume-action.ts +261 -0
- package/src/harness/start-actions.ts +183 -0
- package/src/harness/status-actions.ts +191 -0
- package/src/harness/step-execution-actions.ts +283 -0
- package/src/harness/types.ts +76 -0
- package/src/harness.ts +236 -2303
- package/src/index.ts +73 -11
- package/src/integrations/plannotator-requests.ts +127 -0
- package/src/integrations/plannotator-responses.ts +141 -0
- package/src/integrations/plannotator-types.ts +44 -0
- package/src/integrations/plannotator.ts +16 -235
- package/src/integrations/prompt-gate.ts +17 -10
- package/src/integrations/subagents/child-policy-envelope.ts +119 -0
- package/src/integrations/subagents/child-policy-paths.ts +66 -0
- package/src/integrations/subagents/child-policy-sections.ts +174 -0
- package/src/integrations/subagents/child-policy-types.ts +32 -0
- package/src/integrations/subagents/child-policy-validation.ts +163 -0
- package/src/integrations/subagents/child-runtime-completion.ts +56 -0
- package/src/integrations/subagents/child-runtime-dependencies.ts +47 -0
- package/src/integrations/subagents/child-runtime-files.ts +156 -0
- package/src/integrations/subagents/child-runtime-policy.ts +83 -0
- package/src/integrations/subagents/child-runtime-types.ts +30 -0
- package/src/integrations/subagents/child-runtime.ts +140 -301
- package/src/integrations/subagents/client-delegation.ts +181 -0
- package/src/integrations/subagents/client-messages.ts +66 -0
- package/src/integrations/subagents/client-types.ts +36 -0
- package/src/integrations/subagents/client.ts +127 -219
- package/src/integrations/subagents/delegated-result.ts +31 -0
- package/src/integrations/subagents/diagnostic-format.ts +40 -0
- package/src/integrations/subagents/diagnostic-text.ts +114 -0
- package/src/integrations/subagents/diagnostic-types.ts +77 -0
- package/src/integrations/subagents/diagnostics.ts +24 -977
- package/src/integrations/subagents/failure-correlation.ts +235 -0
- package/src/integrations/subagents/failure-transcript.ts +192 -0
- package/src/integrations/subagents/hidden-bash-failure.ts +98 -0
- package/src/integrations/subagents/protocol-events.ts +27 -0
- package/src/integrations/subagents/protocol.ts +30 -375
- package/src/integrations/subagents/replay-audit.ts +147 -0
- package/src/integrations/subagents/replay-safety.ts +82 -0
- package/src/integrations/subagents/session-diagnostics.ts +258 -0
- package/src/policy/approved-command-extraction.ts +71 -0
- package/src/policy/approved-commands.ts +15 -432
- package/src/policy/bash-authorization.ts +86 -0
- package/src/policy/bash-read-only.ts +161 -0
- package/src/policy/bash-types.ts +20 -0
- package/src/policy/bash.ts +4 -346
- package/src/policy/completion-batch.ts +21 -16
- package/src/policy/immutable-input.ts +5 -2
- package/src/policy/mcp-authorization.ts +71 -0
- package/src/policy/restricted-command.ts +141 -0
- package/src/policy/restricted-git.ts +30 -0
- package/src/policy/reviewed-artifact.ts +97 -0
- package/src/policy/reviewed-command-safety.ts +150 -0
- package/src/policy/reviewed-command-shape.ts +49 -0
- package/src/policy/reviewed-repository-contract.ts +138 -0
- package/src/policy/reviewed-repository-cwd.ts +102 -0
- package/src/policy/tool-call-authorization.ts +58 -0
- package/src/policy/tool-selection.ts +72 -0
- package/src/policy/tool-types.ts +14 -0
- package/src/policy/tools.ts +11 -150
- package/src/preflight.ts +96 -61
- package/src/prompt/main-workflow-notice.ts +41 -0
- package/src/prompt/retry-task.ts +83 -0
- package/src/prompt/step-contract.ts +101 -0
- package/src/prompt/step-sections.ts +75 -0
- package/src/prompt/step-task.ts +156 -0
- package/src/prompt/template.ts +81 -0
- package/src/prompt.ts +10 -254
- package/src/runtime/completion-tool.ts +3 -0
- package/src/runtime/main-step-completion.ts +78 -0
- package/src/runtime/main-step-lifecycle.ts +37 -0
- package/src/runtime/main-step-policy.ts +84 -0
- package/src/runtime/main-step-runtime-types.ts +64 -0
- package/src/runtime/main-step-runtime.ts +129 -200
- package/src/runtime/main-step-state.ts +85 -0
- package/src/runtime/serial-task-queue.ts +48 -13
- package/src/runtime/step-result.ts +48 -27
- package/src/workflow-list.ts +15 -6
- package/src/workflow-status/format-status.ts +52 -0
- package/src/workflow-status/formatting.ts +178 -0
- package/src/workflow-status/layout.ts +109 -0
- package/src/workflow-status/render-board.ts +101 -0
- package/src/workflow-status/render-path.ts +123 -0
- package/src/workflow-status/render-summary.ts +144 -0
- package/src/workflow-status/types.ts +40 -0
- package/src/workflow-status/view.ts +273 -0
- package/src/workflow-status.ts +20 -820
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EMPTY_PERMISSIONS,
|
|
3
|
+
type BashApprovalSource,
|
|
4
|
+
type BashMode,
|
|
5
|
+
type BashPermission,
|
|
6
|
+
type BashRule,
|
|
7
|
+
type StepPermissions,
|
|
8
|
+
type StepRequirements,
|
|
9
|
+
} from '../types.ts';
|
|
10
|
+
import {
|
|
11
|
+
BASH_APPROVAL_SOURCE_PATTERN,
|
|
12
|
+
EXECUTABLE_PATTERN,
|
|
13
|
+
isJsonObject,
|
|
14
|
+
MCP_SELECTOR_PATTERN,
|
|
15
|
+
readString,
|
|
16
|
+
readStringList,
|
|
17
|
+
rejectUnknownKeys,
|
|
18
|
+
RESOURCE_SELECTOR_PATTERN,
|
|
19
|
+
TOOL_PATTERN,
|
|
20
|
+
type ValidationErrors,
|
|
21
|
+
} from './shared.ts';
|
|
22
|
+
|
|
23
|
+
const BASH_APPROVAL_SOURCES = [
|
|
24
|
+
'verification-worker',
|
|
25
|
+
'verification-reviewer',
|
|
26
|
+
'remote-actions',
|
|
27
|
+
] as const satisfies ReadonlyArray<BashApprovalSource>;
|
|
28
|
+
|
|
29
|
+
function isBashApprovalSource(value: string): value is BashApprovalSource {
|
|
30
|
+
return BASH_APPROVAL_SOURCES.some((source) => source === value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function emptyPermissions(): StepPermissions {
|
|
34
|
+
return {
|
|
35
|
+
tools: [],
|
|
36
|
+
mcp: [],
|
|
37
|
+
extensions: [],
|
|
38
|
+
skills: [],
|
|
39
|
+
bash: { mode: 'deny', allow: [] },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function emptyRequirements(): StepRequirements {
|
|
44
|
+
return {
|
|
45
|
+
tools: [],
|
|
46
|
+
extensions: [],
|
|
47
|
+
skills: [],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseBashRule(
|
|
52
|
+
value: unknown,
|
|
53
|
+
path: string,
|
|
54
|
+
errors: ValidationErrors,
|
|
55
|
+
): Array<BashRule> {
|
|
56
|
+
if (!isJsonObject(value)) {
|
|
57
|
+
errors.push(`${path}: expected an object`);
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
rejectUnknownKeys(
|
|
61
|
+
value,
|
|
62
|
+
['executable', 'argsPrefix', 'argsPrefixes'],
|
|
63
|
+
path,
|
|
64
|
+
errors,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const executable = readString(
|
|
68
|
+
value.executable,
|
|
69
|
+
`${path}.executable`,
|
|
70
|
+
errors,
|
|
71
|
+
{ pattern: EXECUTABLE_PATTERN },
|
|
72
|
+
);
|
|
73
|
+
if (value.argsPrefix !== undefined && value.argsPrefixes !== undefined) {
|
|
74
|
+
errors.push(`${path}: argsPrefix and argsPrefixes are mutually exclusive`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (value.argsPrefixes === undefined) {
|
|
78
|
+
const argsPrefix = readStringList(
|
|
79
|
+
value.argsPrefix,
|
|
80
|
+
`${path}.argsPrefix`,
|
|
81
|
+
errors,
|
|
82
|
+
/^[^\s]+$/,
|
|
83
|
+
);
|
|
84
|
+
return executable ? [{ executable, argsPrefix }] : [];
|
|
85
|
+
}
|
|
86
|
+
if (!Array.isArray(value.argsPrefixes)) {
|
|
87
|
+
errors.push(`${path}.argsPrefixes: expected an array of argument arrays`);
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
if (value.argsPrefixes.length === 0) {
|
|
91
|
+
errors.push(`${path}.argsPrefixes: at least one prefix is required`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const prefixes = value.argsPrefixes.reduce<Array<Array<string>>>(
|
|
95
|
+
(result, candidate, index) => {
|
|
96
|
+
const prefixPath = `${path}.argsPrefixes[${index}]`;
|
|
97
|
+
const prefix = readStringList(candidate, prefixPath, errors, /^[^\s]+$/);
|
|
98
|
+
if (Array.isArray(candidate) && candidate.length === 0) {
|
|
99
|
+
errors.push(`${prefixPath}: at least one argument is required`);
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
if (prefix.length === 0) return result;
|
|
103
|
+
if (
|
|
104
|
+
result.some(
|
|
105
|
+
(existing) => JSON.stringify(existing) === JSON.stringify(prefix),
|
|
106
|
+
)
|
|
107
|
+
) {
|
|
108
|
+
errors.push(`${prefixPath}: duplicate argument prefix`);
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
return [...result, prefix];
|
|
112
|
+
},
|
|
113
|
+
[],
|
|
114
|
+
);
|
|
115
|
+
return executable
|
|
116
|
+
? prefixes.map((argsPrefix) => ({ executable, argsPrefix }))
|
|
117
|
+
: [];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parseBashPermission(
|
|
121
|
+
value: unknown,
|
|
122
|
+
path: string,
|
|
123
|
+
errors: ValidationErrors,
|
|
124
|
+
): BashPermission {
|
|
125
|
+
if (value === undefined) {
|
|
126
|
+
return { ...EMPTY_PERMISSIONS.bash, allow: [] };
|
|
127
|
+
}
|
|
128
|
+
if (!isJsonObject(value)) {
|
|
129
|
+
errors.push(`${path}: expected an object`);
|
|
130
|
+
return { ...EMPTY_PERMISSIONS.bash, allow: [] };
|
|
131
|
+
}
|
|
132
|
+
rejectUnknownKeys(value, ['mode', 'allow', 'approvedSources'], path, errors);
|
|
133
|
+
|
|
134
|
+
const mode = readString(value.mode, `${path}.mode`, errors);
|
|
135
|
+
const isValidMode =
|
|
136
|
+
mode === 'deny' ||
|
|
137
|
+
mode === 'read-only' ||
|
|
138
|
+
mode === 'allow-list' ||
|
|
139
|
+
mode === 'unrestricted';
|
|
140
|
+
if (!isValidMode) {
|
|
141
|
+
errors.push(
|
|
142
|
+
`${path}.mode: expected deny, read-only, allow-list, or unrestricted`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const allow = Array.isArray(value.allow)
|
|
147
|
+
? value.allow.flatMap((rule, index) =>
|
|
148
|
+
parseBashRule(rule, `${path}.allow[${index}]`, errors),
|
|
149
|
+
)
|
|
150
|
+
: [];
|
|
151
|
+
if (value.allow !== undefined && !Array.isArray(value.allow)) {
|
|
152
|
+
errors.push(`${path}.allow: expected an array`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const approvedSources = (
|
|
156
|
+
value.approvedSources === undefined
|
|
157
|
+
? []
|
|
158
|
+
: readStringList(
|
|
159
|
+
value.approvedSources,
|
|
160
|
+
`${path}.approvedSources`,
|
|
161
|
+
errors,
|
|
162
|
+
BASH_APPROVAL_SOURCE_PATTERN,
|
|
163
|
+
)
|
|
164
|
+
).filter(isBashApprovalSource);
|
|
165
|
+
|
|
166
|
+
const normalizedMode: BashMode = isValidMode ? mode : 'deny';
|
|
167
|
+
if (normalizedMode !== 'allow-list' && allow.length > 0) {
|
|
168
|
+
errors.push(`${path}.allow: only valid when mode is "allow-list"`);
|
|
169
|
+
}
|
|
170
|
+
if (
|
|
171
|
+
normalizedMode === 'allow-list' &&
|
|
172
|
+
allow.length === 0 &&
|
|
173
|
+
approvedSources.length === 0
|
|
174
|
+
) {
|
|
175
|
+
errors.push(
|
|
176
|
+
`${path}: allow-list mode requires an allow rule or an approved command source`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (normalizedMode !== 'allow-list' && approvedSources.length > 0) {
|
|
180
|
+
errors.push(
|
|
181
|
+
`${path}.approvedSources: only valid when mode is "allow-list"`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
mode: normalizedMode,
|
|
187
|
+
allow,
|
|
188
|
+
...(approvedSources.length > 0 ? { approvedSources } : {}),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Parse the permission block shared by workflow steps and user ceilings. */
|
|
193
|
+
export function parsePermissions(
|
|
194
|
+
value: unknown,
|
|
195
|
+
path: string,
|
|
196
|
+
errors: ValidationErrors,
|
|
197
|
+
): StepPermissions {
|
|
198
|
+
if (value === undefined) {
|
|
199
|
+
return emptyPermissions();
|
|
200
|
+
}
|
|
201
|
+
if (!isJsonObject(value)) {
|
|
202
|
+
errors.push(`${path}: expected an object`);
|
|
203
|
+
return emptyPermissions();
|
|
204
|
+
}
|
|
205
|
+
rejectUnknownKeys(
|
|
206
|
+
value,
|
|
207
|
+
['tools', 'mcp', 'extensions', 'skills', 'bash'],
|
|
208
|
+
path,
|
|
209
|
+
errors,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const permissions: StepPermissions = {
|
|
213
|
+
tools: readStringList(value.tools, `${path}.tools`, errors, TOOL_PATTERN),
|
|
214
|
+
mcp: readStringList(value.mcp, `${path}.mcp`, errors, MCP_SELECTOR_PATTERN),
|
|
215
|
+
extensions: readStringList(
|
|
216
|
+
value.extensions,
|
|
217
|
+
`${path}.extensions`,
|
|
218
|
+
errors,
|
|
219
|
+
RESOURCE_SELECTOR_PATTERN,
|
|
220
|
+
),
|
|
221
|
+
skills: readStringList(
|
|
222
|
+
value.skills,
|
|
223
|
+
`${path}.skills`,
|
|
224
|
+
errors,
|
|
225
|
+
RESOURCE_SELECTOR_PATTERN,
|
|
226
|
+
),
|
|
227
|
+
bash: parseBashPermission(value.bash, `${path}.bash`, errors),
|
|
228
|
+
};
|
|
229
|
+
if (permissions.bash.mode !== 'deny' && !permissions.tools.includes('bash')) {
|
|
230
|
+
errors.push(`${path}.tools: must include "bash" when Bash is enabled`);
|
|
231
|
+
}
|
|
232
|
+
return permissions;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Parse requirements and ensure every requirement is permitted by the step. */
|
|
236
|
+
export function parseRequirements(
|
|
237
|
+
value: unknown,
|
|
238
|
+
permissions: StepPermissions,
|
|
239
|
+
path: string,
|
|
240
|
+
errors: ValidationErrors,
|
|
241
|
+
): StepRequirements {
|
|
242
|
+
if (value === undefined) return emptyRequirements();
|
|
243
|
+
if (!isJsonObject(value)) {
|
|
244
|
+
errors.push(`${path}: expected an object`);
|
|
245
|
+
return emptyRequirements();
|
|
246
|
+
}
|
|
247
|
+
rejectUnknownKeys(value, ['tools', 'extensions', 'skills'], path, errors);
|
|
248
|
+
|
|
249
|
+
const requirements: StepRequirements = {
|
|
250
|
+
tools: readStringList(value.tools, `${path}.tools`, errors, TOOL_PATTERN),
|
|
251
|
+
extensions: readStringList(
|
|
252
|
+
value.extensions,
|
|
253
|
+
`${path}.extensions`,
|
|
254
|
+
errors,
|
|
255
|
+
RESOURCE_SELECTOR_PATTERN,
|
|
256
|
+
),
|
|
257
|
+
skills: readStringList(
|
|
258
|
+
value.skills,
|
|
259
|
+
`${path}.skills`,
|
|
260
|
+
errors,
|
|
261
|
+
RESOURCE_SELECTOR_PATTERN,
|
|
262
|
+
),
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
requirements.tools
|
|
266
|
+
.filter(
|
|
267
|
+
(tool) =>
|
|
268
|
+
!permissions.tools.includes(tool) &&
|
|
269
|
+
!(tool === 'mcp' && permissions.mcp.length > 0),
|
|
270
|
+
)
|
|
271
|
+
.forEach((tool) =>
|
|
272
|
+
errors.push(
|
|
273
|
+
`${path}.tools: required tool "${tool}" is not allowed by this step`,
|
|
274
|
+
),
|
|
275
|
+
);
|
|
276
|
+
requirements.extensions
|
|
277
|
+
.filter((extension) => !permissions.extensions.includes(extension))
|
|
278
|
+
.forEach((extension) =>
|
|
279
|
+
errors.push(
|
|
280
|
+
`${path}.extensions: required extension "${extension}" is not allowed by this step`,
|
|
281
|
+
),
|
|
282
|
+
);
|
|
283
|
+
requirements.skills
|
|
284
|
+
.filter((skill) => !permissions.skills.includes(skill))
|
|
285
|
+
.forEach((skill) =>
|
|
286
|
+
errors.push(
|
|
287
|
+
`${path}.skills: required skill "${skill}" is not allowed by this step`,
|
|
288
|
+
),
|
|
289
|
+
);
|
|
290
|
+
return requirements;
|
|
291
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const PROMPT_VARIABLES = new Set([
|
|
2
|
+
'workflow.input',
|
|
3
|
+
'workflow.id',
|
|
4
|
+
'run.id',
|
|
5
|
+
'step.id',
|
|
6
|
+
'step.title',
|
|
7
|
+
'last.summary',
|
|
8
|
+
'gate.feedback',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
/** Validate template variables embedded in resolved prompt text. */
|
|
12
|
+
export function validatePromptText(text: string, path: string): Array<string> {
|
|
13
|
+
return [...text.matchAll(/\{\{([^{}]+)\}\}/g)].flatMap((match) => {
|
|
14
|
+
const variable = match[1]?.trim() ?? '';
|
|
15
|
+
return PROMPT_VARIABLES.has(variable)
|
|
16
|
+
? []
|
|
17
|
+
: [`${path}: unknown prompt variable "{{${variable}}}"`];
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_SETTINGS,
|
|
3
|
+
WORKFLOW_SCHEMA_VERSION,
|
|
4
|
+
type PermissionCeiling,
|
|
5
|
+
type WorkflowSettings,
|
|
6
|
+
} from '../types.ts';
|
|
7
|
+
import { parsePermissions } from './permissions.ts';
|
|
8
|
+
import {
|
|
9
|
+
isJsonObject,
|
|
10
|
+
rejectUnknownKeys,
|
|
11
|
+
type ValidationErrors,
|
|
12
|
+
type ValidationResult,
|
|
13
|
+
} from './shared.ts';
|
|
14
|
+
import { readStatusShortcut } from './shortcut.ts';
|
|
15
|
+
import { parseSubagentPermissionCeiling } from './subagent.ts';
|
|
16
|
+
|
|
17
|
+
function parsePermissionCeiling(
|
|
18
|
+
value: unknown,
|
|
19
|
+
path: string,
|
|
20
|
+
errors: ValidationErrors,
|
|
21
|
+
): PermissionCeiling | undefined {
|
|
22
|
+
if (value === undefined) return undefined;
|
|
23
|
+
if (!isJsonObject(value)) {
|
|
24
|
+
errors.push(`${path}: expected an object`);
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
rejectUnknownKeys(
|
|
28
|
+
value,
|
|
29
|
+
['tools', 'mcp', 'extensions', 'skills', 'bash', 'subagent'],
|
|
30
|
+
path,
|
|
31
|
+
errors,
|
|
32
|
+
);
|
|
33
|
+
const permissions = parsePermissions(
|
|
34
|
+
{
|
|
35
|
+
...(value.tools !== undefined ? { tools: value.tools } : {}),
|
|
36
|
+
...(value.mcp !== undefined ? { mcp: value.mcp } : {}),
|
|
37
|
+
...(value.extensions !== undefined
|
|
38
|
+
? { extensions: value.extensions }
|
|
39
|
+
: {}),
|
|
40
|
+
...(value.skills !== undefined ? { skills: value.skills } : {}),
|
|
41
|
+
...(value.bash !== undefined ? { bash: value.bash } : {}),
|
|
42
|
+
},
|
|
43
|
+
path,
|
|
44
|
+
errors,
|
|
45
|
+
);
|
|
46
|
+
const subagent =
|
|
47
|
+
value.subagent === undefined
|
|
48
|
+
? undefined
|
|
49
|
+
: parseSubagentPermissionCeiling(
|
|
50
|
+
value.subagent,
|
|
51
|
+
`${path}.subagent`,
|
|
52
|
+
errors,
|
|
53
|
+
);
|
|
54
|
+
return {
|
|
55
|
+
...permissions,
|
|
56
|
+
...(subagent ? { subagent } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Validate and normalize untrusted user workflow settings. */
|
|
61
|
+
export function validateSettings(
|
|
62
|
+
value: unknown,
|
|
63
|
+
): ValidationResult<WorkflowSettings> {
|
|
64
|
+
const errors: ValidationErrors = [];
|
|
65
|
+
if (!isJsonObject(value)) {
|
|
66
|
+
return { errors: ['settings: expected an object'] };
|
|
67
|
+
}
|
|
68
|
+
rejectUnknownKeys(
|
|
69
|
+
value,
|
|
70
|
+
[
|
|
71
|
+
'$schema',
|
|
72
|
+
'version',
|
|
73
|
+
'allowProjectWorkflows',
|
|
74
|
+
'statusShortcut',
|
|
75
|
+
'permissionCeiling',
|
|
76
|
+
],
|
|
77
|
+
'settings',
|
|
78
|
+
errors,
|
|
79
|
+
);
|
|
80
|
+
if (value.$schema !== undefined && typeof value.$schema !== 'string') {
|
|
81
|
+
errors.push('settings.$schema: expected a string');
|
|
82
|
+
}
|
|
83
|
+
if (value.version !== WORKFLOW_SCHEMA_VERSION) {
|
|
84
|
+
errors.push(`settings.version: expected ${WORKFLOW_SCHEMA_VERSION}`);
|
|
85
|
+
}
|
|
86
|
+
const allowProjectWorkflows =
|
|
87
|
+
typeof value.allowProjectWorkflows === 'boolean'
|
|
88
|
+
? value.allowProjectWorkflows
|
|
89
|
+
: false;
|
|
90
|
+
if (
|
|
91
|
+
value.allowProjectWorkflows !== undefined &&
|
|
92
|
+
typeof value.allowProjectWorkflows !== 'boolean'
|
|
93
|
+
) {
|
|
94
|
+
errors.push('settings.allowProjectWorkflows: expected a boolean');
|
|
95
|
+
}
|
|
96
|
+
const statusShortcut = readStatusShortcut(
|
|
97
|
+
value.statusShortcut,
|
|
98
|
+
'settings.statusShortcut',
|
|
99
|
+
errors,
|
|
100
|
+
);
|
|
101
|
+
const permissionCeiling = parsePermissionCeiling(
|
|
102
|
+
value.permissionCeiling,
|
|
103
|
+
'settings.permissionCeiling',
|
|
104
|
+
errors,
|
|
105
|
+
);
|
|
106
|
+
if (allowProjectWorkflows && !permissionCeiling) {
|
|
107
|
+
errors.push(
|
|
108
|
+
'settings.permissionCeiling: required when project workflows are enabled',
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (errors.length > 0) return { errors };
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
value: {
|
|
115
|
+
...DEFAULT_SETTINGS,
|
|
116
|
+
allowProjectWorkflows,
|
|
117
|
+
statusShortcut,
|
|
118
|
+
...(permissionCeiling ? { permissionCeiling } : {}),
|
|
119
|
+
},
|
|
120
|
+
errors,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
export type ValidationResult<TValue> = {
|
|
2
|
+
readonly value?: TValue;
|
|
3
|
+
readonly errors: ReadonlyArray<string>;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type JsonObject = Record<string, unknown>;
|
|
7
|
+
export type ValidationErrors = Array<string>;
|
|
8
|
+
|
|
9
|
+
export const IDENTIFIER_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
|
|
10
|
+
export const OUTCOME_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
|
|
11
|
+
export const TOOL_PATTERN = /^[A-Za-z0-9_.:-]+$/;
|
|
12
|
+
export const RESOURCE_SELECTOR_PATTERN = /^[A-Za-z0-9_@./:+-]+$/;
|
|
13
|
+
export const MCP_SELECTOR_PATTERN = /^[A-Za-z0-9_.:-]+(?:\/[A-Za-z0-9_.:-]+)?$/;
|
|
14
|
+
export const EXECUTABLE_PATTERN = /^[A-Za-z0-9_./+-]+$/;
|
|
15
|
+
export const BASH_APPROVAL_SOURCE_PATTERN =
|
|
16
|
+
/^(verification-worker|verification-reviewer|remote-actions)$/;
|
|
17
|
+
|
|
18
|
+
type StringOptions = {
|
|
19
|
+
readonly pattern?: RegExp;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type IntegerLimits = {
|
|
23
|
+
readonly min: number;
|
|
24
|
+
readonly max: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Return whether an unknown value is a plain JSON-style object. */
|
|
28
|
+
export function isJsonObject(value: unknown): value is JsonObject {
|
|
29
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Append diagnostics for object properties outside the supplied allow-list. */
|
|
33
|
+
export function rejectUnknownKeys(
|
|
34
|
+
value: JsonObject,
|
|
35
|
+
allowed: ReadonlyArray<string>,
|
|
36
|
+
path: string,
|
|
37
|
+
errors: ValidationErrors,
|
|
38
|
+
): void {
|
|
39
|
+
const allowedKeys = new Set(allowed);
|
|
40
|
+
Object.keys(value)
|
|
41
|
+
.filter((key) => !allowedKeys.has(key))
|
|
42
|
+
.forEach((key) => errors.push(`${path}: unknown property "${key}"`));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Parse and normalize a string, collecting a diagnostic when it is invalid. */
|
|
46
|
+
export function readString(
|
|
47
|
+
value: unknown,
|
|
48
|
+
path: string,
|
|
49
|
+
errors: ValidationErrors,
|
|
50
|
+
options: StringOptions = {},
|
|
51
|
+
): string | undefined {
|
|
52
|
+
if (typeof value !== 'string') {
|
|
53
|
+
errors.push(`${path}: expected a string`);
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const result = value.trim();
|
|
58
|
+
if (result.length === 0) {
|
|
59
|
+
errors.push(`${path}: must not be empty`);
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
if (options.pattern && !options.pattern.test(result)) {
|
|
63
|
+
errors.push(`${path}: invalid value "${result}"`);
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Parse a bounded integer, returning the supplied fallback on invalid input. */
|
|
70
|
+
export function readInteger(
|
|
71
|
+
value: unknown,
|
|
72
|
+
fallback: number,
|
|
73
|
+
path: string,
|
|
74
|
+
errors: ValidationErrors,
|
|
75
|
+
limits: IntegerLimits,
|
|
76
|
+
): number {
|
|
77
|
+
if (value === undefined) return fallback;
|
|
78
|
+
if (
|
|
79
|
+
typeof value !== 'number' ||
|
|
80
|
+
!Number.isInteger(value) ||
|
|
81
|
+
value < limits.min ||
|
|
82
|
+
value > limits.max
|
|
83
|
+
) {
|
|
84
|
+
errors.push(
|
|
85
|
+
`${path}: expected an integer from ${limits.min} to ${limits.max}`,
|
|
86
|
+
);
|
|
87
|
+
return fallback;
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Parse a boolean, returning the supplied fallback on invalid input. */
|
|
93
|
+
export function readBoolean(
|
|
94
|
+
value: unknown,
|
|
95
|
+
fallback: boolean,
|
|
96
|
+
path: string,
|
|
97
|
+
errors: ValidationErrors,
|
|
98
|
+
): boolean {
|
|
99
|
+
if (value === undefined) return fallback;
|
|
100
|
+
if (typeof value !== 'boolean') {
|
|
101
|
+
errors.push(`${path}: expected a boolean`);
|
|
102
|
+
return fallback;
|
|
103
|
+
}
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Parse a duplicate-free array of patterned strings. */
|
|
108
|
+
export function readStringList(
|
|
109
|
+
value: unknown,
|
|
110
|
+
path: string,
|
|
111
|
+
errors: ValidationErrors,
|
|
112
|
+
pattern: RegExp,
|
|
113
|
+
): Array<string> {
|
|
114
|
+
if (value === undefined) return [];
|
|
115
|
+
if (!Array.isArray(value)) {
|
|
116
|
+
errors.push(`${path}: expected an array of strings`);
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return value.reduce<Array<string>>((result, item, index) => {
|
|
121
|
+
const parsed = readString(item, `${path}[${index}]`, errors, { pattern });
|
|
122
|
+
if (!parsed) return result;
|
|
123
|
+
if (result.includes(parsed)) {
|
|
124
|
+
errors.push(`${path}[${index}]: duplicate value "${parsed}"`);
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
return [...result, parsed];
|
|
128
|
+
}, []);
|
|
129
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { WorkflowSettings } from '../types.ts';
|
|
2
|
+
import { DEFAULT_STATUS_SHORTCUT } from '../types.ts';
|
|
3
|
+
import { readString, type ValidationErrors } from './shared.ts';
|
|
4
|
+
|
|
5
|
+
const SHORTCUT_MODIFIERS = new Set(['ctrl', 'shift', 'alt', 'super']);
|
|
6
|
+
const SHORTCUT_NAMED_KEYS = new Map<string, string>([
|
|
7
|
+
['escape', 'escape'],
|
|
8
|
+
['esc', 'esc'],
|
|
9
|
+
['enter', 'enter'],
|
|
10
|
+
['return', 'return'],
|
|
11
|
+
['tab', 'tab'],
|
|
12
|
+
['space', 'space'],
|
|
13
|
+
['backspace', 'backspace'],
|
|
14
|
+
['delete', 'delete'],
|
|
15
|
+
['insert', 'insert'],
|
|
16
|
+
['clear', 'clear'],
|
|
17
|
+
['home', 'home'],
|
|
18
|
+
['end', 'end'],
|
|
19
|
+
['pageup', 'pageUp'],
|
|
20
|
+
['pagedown', 'pageDown'],
|
|
21
|
+
['up', 'up'],
|
|
22
|
+
['down', 'down'],
|
|
23
|
+
['left', 'left'],
|
|
24
|
+
['right', 'right'],
|
|
25
|
+
]);
|
|
26
|
+
const SHORTCUT_SYMBOL_KEYS = new Set([
|
|
27
|
+
'`',
|
|
28
|
+
'-',
|
|
29
|
+
'=',
|
|
30
|
+
'[',
|
|
31
|
+
']',
|
|
32
|
+
'\\',
|
|
33
|
+
';',
|
|
34
|
+
"'",
|
|
35
|
+
',',
|
|
36
|
+
'.',
|
|
37
|
+
'/',
|
|
38
|
+
'!',
|
|
39
|
+
'@',
|
|
40
|
+
'#',
|
|
41
|
+
'$',
|
|
42
|
+
'%',
|
|
43
|
+
'^',
|
|
44
|
+
'&',
|
|
45
|
+
'*',
|
|
46
|
+
'(',
|
|
47
|
+
')',
|
|
48
|
+
'_',
|
|
49
|
+
'|',
|
|
50
|
+
'~',
|
|
51
|
+
'{',
|
|
52
|
+
'}',
|
|
53
|
+
':',
|
|
54
|
+
'<',
|
|
55
|
+
'>',
|
|
56
|
+
'?',
|
|
57
|
+
]);
|
|
58
|
+
const MAX_SHORTCUT_CHARS = 64;
|
|
59
|
+
|
|
60
|
+
function canonicalShortcutKey(value: string): string | undefined {
|
|
61
|
+
if (/^[a-z0-9]$/.test(value) || SHORTCUT_SYMBOL_KEYS.has(value)) {
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
if (/^f(?:[1-9]|1[0-2])$/.test(value)) return value;
|
|
65
|
+
return SHORTCUT_NAMED_KEYS.get(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isStatusShortcut(
|
|
69
|
+
value: string,
|
|
70
|
+
): value is WorkflowSettings['statusShortcut'] {
|
|
71
|
+
const parts = value.split('+');
|
|
72
|
+
const key = parts.pop() ?? '';
|
|
73
|
+
return (
|
|
74
|
+
canonicalShortcutKey(key.toLowerCase()) === key &&
|
|
75
|
+
parts.every((modifier) => SHORTCUT_MODIFIERS.has(modifier))
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Parse and normalize the configured workflow status shortcut. */
|
|
80
|
+
export function readStatusShortcut(
|
|
81
|
+
value: unknown,
|
|
82
|
+
path: string,
|
|
83
|
+
errors: ValidationErrors,
|
|
84
|
+
): WorkflowSettings['statusShortcut'] {
|
|
85
|
+
if (value === undefined) return DEFAULT_STATUS_SHORTCUT;
|
|
86
|
+
const shortcut = readString(value, path, errors);
|
|
87
|
+
if (!shortcut) return DEFAULT_STATUS_SHORTCUT;
|
|
88
|
+
if (shortcut.length > MAX_SHORTCUT_CHARS) {
|
|
89
|
+
errors.push(`${path}: must be at most ${MAX_SHORTCUT_CHARS} characters`);
|
|
90
|
+
return DEFAULT_STATUS_SHORTCUT;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const parts = shortcut.toLowerCase().split('+');
|
|
94
|
+
const key = parts.pop() ?? '';
|
|
95
|
+
const modifiers = parts;
|
|
96
|
+
const uniqueModifiers = new Set(modifiers);
|
|
97
|
+
const canonicalKey = canonicalShortcutKey(key);
|
|
98
|
+
const isPlainTypingKey =
|
|
99
|
+
modifiers.length === 0 && (key.length === 1 || key === 'space');
|
|
100
|
+
const isUnmatchableModifiedKey =
|
|
101
|
+
modifiers.length > 0 &&
|
|
102
|
+
(key === 'escape' || key === 'esc' || /^f(?:[1-9]|1[0-2])$/.test(key));
|
|
103
|
+
const isInvalid =
|
|
104
|
+
!canonicalKey ||
|
|
105
|
+
modifiers.length > SHORTCUT_MODIFIERS.size ||
|
|
106
|
+
uniqueModifiers.size !== modifiers.length ||
|
|
107
|
+
modifiers.some((modifier) => !SHORTCUT_MODIFIERS.has(modifier)) ||
|
|
108
|
+
isPlainTypingKey ||
|
|
109
|
+
isUnmatchableModifiedKey;
|
|
110
|
+
|
|
111
|
+
if (isInvalid) {
|
|
112
|
+
errors.push(`${path}: expected a supported Pi key id such as "ctrl+alt+w"`);
|
|
113
|
+
return DEFAULT_STATUS_SHORTCUT;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const normalized = [...modifiers, canonicalKey].join('+');
|
|
117
|
+
return isStatusShortcut(normalized) ? normalized : DEFAULT_STATUS_SHORTCUT;
|
|
118
|
+
}
|