@wichayutdew/pi-workflows 0.3.0 → 1.0.1
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 +55 -39
- package/dist/index.js +5771 -4794
- package/examples/mr-comments.workflow.yaml +2 -0
- package/package.json +1 -1
- package/schemas/workflow.schema.json +10 -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 +120 -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 +150 -137
- package/src/config/validate.ts +12 -1261
- package/src/config/validation/permissions.ts +312 -0
- package/src/config/validation/prompt.ts +21 -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 +43 -0
- package/src/engine/gate-transitions.ts +187 -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 +129 -0
- package/src/engine/state-types.ts +66 -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 +186 -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 +83 -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,20 @@
|
|
|
1
|
+
export type BashAuthorization = {
|
|
2
|
+
readonly allowed: boolean;
|
|
3
|
+
readonly reason?: string;
|
|
4
|
+
readonly tokens?: Array<string>;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type RestrictedCommandTokens =
|
|
8
|
+
| {
|
|
9
|
+
readonly tokens: Array<string>;
|
|
10
|
+
readonly error?: never;
|
|
11
|
+
}
|
|
12
|
+
| {
|
|
13
|
+
readonly tokens?: never;
|
|
14
|
+
readonly error: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type RestrictedGitCommand = {
|
|
18
|
+
readonly subcommand: string;
|
|
19
|
+
readonly subcommandIndex: number;
|
|
20
|
+
};
|
package/src/policy/bash.ts
CHANGED
|
@@ -1,346 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
';',
|
|
6
|
-
'&',
|
|
7
|
-
'|',
|
|
8
|
-
'<',
|
|
9
|
-
'>',
|
|
10
|
-
'\n',
|
|
11
|
-
'\r',
|
|
12
|
-
'`',
|
|
13
|
-
'$',
|
|
14
|
-
'(',
|
|
15
|
-
')',
|
|
16
|
-
'{',
|
|
17
|
-
'}',
|
|
18
|
-
'#',
|
|
19
|
-
'\0',
|
|
20
|
-
]);
|
|
21
|
-
const PATHNAME_EXPANSION = new Set(['*', '?', '[', ']', '~']);
|
|
22
|
-
const WRAPPER_COMMANDS = new Set([
|
|
23
|
-
'bash',
|
|
24
|
-
'builtin',
|
|
25
|
-
'command',
|
|
26
|
-
'env',
|
|
27
|
-
'exec',
|
|
28
|
-
'fish',
|
|
29
|
-
'sh',
|
|
30
|
-
'time',
|
|
31
|
-
'xargs',
|
|
32
|
-
'zsh',
|
|
33
|
-
]);
|
|
34
|
-
const READ_ONLY_EXECUTABLES = new Set([
|
|
35
|
-
'grep',
|
|
36
|
-
'head',
|
|
37
|
-
'ls',
|
|
38
|
-
'pwd',
|
|
39
|
-
'rg',
|
|
40
|
-
'stat',
|
|
41
|
-
'tail',
|
|
42
|
-
'wc',
|
|
43
|
-
]);
|
|
44
|
-
const READ_ONLY_GIT_SUBCOMMANDS = new Set([
|
|
45
|
-
'diff',
|
|
46
|
-
'grep',
|
|
47
|
-
'log',
|
|
48
|
-
'ls-files',
|
|
49
|
-
'rev-parse',
|
|
50
|
-
'show',
|
|
51
|
-
'status',
|
|
52
|
-
]);
|
|
53
|
-
const DANGEROUS_GIT_OPTIONS = [
|
|
54
|
-
'--config-env',
|
|
55
|
-
'--exec',
|
|
56
|
-
'--ext-diff',
|
|
57
|
-
'--open-files-in-pager',
|
|
58
|
-
'--output',
|
|
59
|
-
'--textconv',
|
|
60
|
-
];
|
|
61
|
-
const DANGEROUS_GIT_SHORT_OPTIONS: ReadonlyArray<{
|
|
62
|
-
subcommand: string;
|
|
63
|
-
option: string;
|
|
64
|
-
}> = [
|
|
65
|
-
// `git grep -O<pager>` executes the supplied pager command.
|
|
66
|
-
{ subcommand: 'grep', option: '-O' },
|
|
67
|
-
];
|
|
68
|
-
const HOSTED_API_MUTATION_OPTIONS = [
|
|
69
|
-
'--field',
|
|
70
|
-
'--form',
|
|
71
|
-
'--input',
|
|
72
|
-
'--method',
|
|
73
|
-
'--raw-field',
|
|
74
|
-
'-F',
|
|
75
|
-
'-X',
|
|
76
|
-
'-f',
|
|
77
|
-
];
|
|
78
|
-
|
|
79
|
-
export interface BashAuthorization {
|
|
80
|
-
allowed: boolean;
|
|
81
|
-
reason?: string;
|
|
82
|
-
tokens?: string[];
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function reject(reason: string): BashAuthorization {
|
|
86
|
-
return { allowed: false, reason };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Tokenize the deliberately small shell subset accepted by restricted modes.
|
|
91
|
-
* Shell operators, substitutions, expansions, and comments are rejected first.
|
|
92
|
-
*/
|
|
93
|
-
export function tokenizeRestrictedCommand(command: string): ValidationTokens {
|
|
94
|
-
if (!command.trim()) return { error: 'empty Bash command' };
|
|
95
|
-
|
|
96
|
-
const tokens: string[] = [];
|
|
97
|
-
let token = '';
|
|
98
|
-
let quote: "'" | '"' | undefined;
|
|
99
|
-
let escaping = false;
|
|
100
|
-
let tokenStarted = false;
|
|
101
|
-
|
|
102
|
-
for (const character of command) {
|
|
103
|
-
if (quote === "'") {
|
|
104
|
-
if (character === '\n' || character === '\r' || character === '\0') {
|
|
105
|
-
return { error: 'multiline and null characters are not allowed' };
|
|
106
|
-
}
|
|
107
|
-
if (character === "'") {
|
|
108
|
-
quote = undefined;
|
|
109
|
-
} else {
|
|
110
|
-
token += character;
|
|
111
|
-
}
|
|
112
|
-
tokenStarted = true;
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
if (quote === '"') {
|
|
116
|
-
if (character === '\n' || character === '\r' || character === '\0') {
|
|
117
|
-
return { error: 'multiline and null characters are not allowed' };
|
|
118
|
-
}
|
|
119
|
-
if (character === '"') {
|
|
120
|
-
quote = undefined;
|
|
121
|
-
} else if (character === '$' || character === '`' || character === '\\') {
|
|
122
|
-
return {
|
|
123
|
-
error:
|
|
124
|
-
'substitutions and escapes are not allowed inside double quotes',
|
|
125
|
-
};
|
|
126
|
-
} else {
|
|
127
|
-
token += character;
|
|
128
|
-
}
|
|
129
|
-
tokenStarted = true;
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
if (escaping) {
|
|
133
|
-
if (character === '\n' || character === '\r' || character === '\0') {
|
|
134
|
-
return { error: 'multiline and null characters are not allowed' };
|
|
135
|
-
}
|
|
136
|
-
token += character;
|
|
137
|
-
escaping = false;
|
|
138
|
-
tokenStarted = true;
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
if (character === '\\') {
|
|
142
|
-
escaping = true;
|
|
143
|
-
tokenStarted = true;
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
if (character === "'" || character === '"') {
|
|
147
|
-
quote = character;
|
|
148
|
-
tokenStarted = true;
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
if (UNQUOTED_SHELL_META.has(character)) {
|
|
152
|
-
return {
|
|
153
|
-
error:
|
|
154
|
-
'shell operators, substitutions, expansions, and comments are not allowed',
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
if (PATHNAME_EXPANSION.has(character)) {
|
|
158
|
-
return {
|
|
159
|
-
error: 'unquoted pathname and tilde expansion are not allowed',
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
if (/\s/.test(character)) {
|
|
163
|
-
if (tokenStarted) {
|
|
164
|
-
tokens.push(token);
|
|
165
|
-
token = '';
|
|
166
|
-
tokenStarted = false;
|
|
167
|
-
}
|
|
168
|
-
continue;
|
|
169
|
-
}
|
|
170
|
-
token += character;
|
|
171
|
-
tokenStarted = true;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (escaping) return { error: 'trailing Bash escape is not allowed' };
|
|
175
|
-
if (quote) return { error: 'unterminated Bash quote' };
|
|
176
|
-
if (tokenStarted) tokens.push(token);
|
|
177
|
-
if (tokens.length === 0) return { error: 'empty Bash command' };
|
|
178
|
-
return { tokens };
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
interface ValidationTokens {
|
|
182
|
-
tokens?: string[];
|
|
183
|
-
error?: string;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export interface RestrictedGitCommand {
|
|
187
|
-
subcommand: string;
|
|
188
|
-
subcommandIndex: number;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export function parseRestrictedGitCommand(
|
|
192
|
-
tokens: readonly string[],
|
|
193
|
-
): RestrictedGitCommand | undefined {
|
|
194
|
-
let index = 1;
|
|
195
|
-
while (index < tokens.length) {
|
|
196
|
-
const token = tokens[index];
|
|
197
|
-
if (token === '-C') {
|
|
198
|
-
if (!tokens[index + 1]) return undefined;
|
|
199
|
-
index += 2;
|
|
200
|
-
continue;
|
|
201
|
-
}
|
|
202
|
-
if (token === '--no-pager') {
|
|
203
|
-
index += 1;
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
if (!token || token.startsWith('-')) return undefined;
|
|
207
|
-
return { subcommand: token, subcommandIndex: index };
|
|
208
|
-
}
|
|
209
|
-
return undefined;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function matchesRule(tokens: readonly string[], rule: BashRule): boolean {
|
|
213
|
-
if (tokens[0] !== rule.executable) return false;
|
|
214
|
-
return rule.argsPrefix.every(
|
|
215
|
-
(expected, index) => tokens[index + 1] === expected,
|
|
216
|
-
);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function hasOption(tokens: readonly string[], option: string): boolean {
|
|
220
|
-
return tokens.some(
|
|
221
|
-
(token) => token === option || token.startsWith(`${option}=`),
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function authorizeReadOnly(tokens: readonly string[]): BashAuthorization {
|
|
226
|
-
const executable = tokens[0] ?? '';
|
|
227
|
-
if (READ_ONLY_EXECUTABLES.has(executable)) {
|
|
228
|
-
if (
|
|
229
|
-
executable === 'rg' &&
|
|
230
|
-
(hasOption(tokens, '--pre') || hasOption(tokens, '--pre-glob'))
|
|
231
|
-
) {
|
|
232
|
-
return reject('rg preprocessors are not allowed in read-only mode');
|
|
233
|
-
}
|
|
234
|
-
return { allowed: true, tokens: [...tokens] };
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
if (executable !== 'git') {
|
|
238
|
-
return reject(`"${executable}" is not in the read-only Bash preset`);
|
|
239
|
-
}
|
|
240
|
-
const gitCommand = parseRestrictedGitCommand(tokens);
|
|
241
|
-
if (!gitCommand || !READ_ONLY_GIT_SUBCOMMANDS.has(gitCommand.subcommand)) {
|
|
242
|
-
return reject(
|
|
243
|
-
`git subcommand "${gitCommand?.subcommand ?? ''}" is not read-only`,
|
|
244
|
-
);
|
|
245
|
-
}
|
|
246
|
-
const dangerousOption = DANGEROUS_GIT_OPTIONS.find((option) =>
|
|
247
|
-
hasOption(tokens, option),
|
|
248
|
-
);
|
|
249
|
-
if (dangerousOption) {
|
|
250
|
-
return reject(
|
|
251
|
-
`git option "${dangerousOption}" is not allowed in read-only mode`,
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
const dangerousShortOption = DANGEROUS_GIT_SHORT_OPTIONS.find(
|
|
255
|
-
({ subcommand: matchedSubcommand, option }) =>
|
|
256
|
-
gitCommand.subcommand === matchedSubcommand &&
|
|
257
|
-
tokens
|
|
258
|
-
.slice(gitCommand.subcommandIndex + 1)
|
|
259
|
-
.some((token) => token === option || token.startsWith(option)),
|
|
260
|
-
);
|
|
261
|
-
if (dangerousShortOption) {
|
|
262
|
-
return reject(
|
|
263
|
-
`git option "${dangerousShortOption.option}" is not allowed in read-only mode`,
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
|
-
return { allowed: true, tokens: [...tokens] };
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
function usesReadOnlyPreset(tokens: readonly string[]): boolean {
|
|
270
|
-
const executable = tokens[0] ?? '';
|
|
271
|
-
return (
|
|
272
|
-
READ_ONLY_EXECUTABLES.has(executable) ||
|
|
273
|
-
(executable === 'git' &&
|
|
274
|
-
READ_ONLY_GIT_SUBCOMMANDS.has(
|
|
275
|
-
parseRestrictedGitCommand(tokens)?.subcommand ?? '',
|
|
276
|
-
))
|
|
277
|
-
);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function authorizeHostedApiRead(tokens: readonly string[]): BashAuthorization {
|
|
281
|
-
const executable = basename(tokens[0] ?? '');
|
|
282
|
-
if ((executable !== 'gh' && executable !== 'glab') || tokens[1] !== 'api') {
|
|
283
|
-
return { allowed: true, tokens: [...tokens] };
|
|
284
|
-
}
|
|
285
|
-
const mutationOption = HOSTED_API_MUTATION_OPTIONS.find((option) =>
|
|
286
|
-
tokens
|
|
287
|
-
.slice(2)
|
|
288
|
-
.some(
|
|
289
|
-
(token) =>
|
|
290
|
-
token === option ||
|
|
291
|
-
token.startsWith(`${option}=`) ||
|
|
292
|
-
(option.length === 2 && token.startsWith(option)),
|
|
293
|
-
),
|
|
294
|
-
);
|
|
295
|
-
if (mutationOption) {
|
|
296
|
-
return reject(
|
|
297
|
-
`${executable} api option "${mutationOption}" is not allowed by a static read-only rule`,
|
|
298
|
-
);
|
|
299
|
-
}
|
|
300
|
-
return { allowed: true, tokens: [...tokens] };
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
export function authorizeBash(
|
|
304
|
-
command: string,
|
|
305
|
-
permission: BashPermission,
|
|
306
|
-
approvedCommands: readonly string[] = [],
|
|
307
|
-
): BashAuthorization {
|
|
308
|
-
if (
|
|
309
|
-
(permission.approvedSources?.length ?? 0) > 0 &&
|
|
310
|
-
approvedCommands.includes(command)
|
|
311
|
-
) {
|
|
312
|
-
return { allowed: true };
|
|
313
|
-
}
|
|
314
|
-
if (permission.mode === 'unrestricted') {
|
|
315
|
-
return { allowed: true };
|
|
316
|
-
}
|
|
317
|
-
if (permission.mode === 'deny') {
|
|
318
|
-
return reject('Bash is disabled for this workflow step');
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
const parsed = tokenizeRestrictedCommand(command);
|
|
322
|
-
if (!parsed.tokens) return reject(parsed.error ?? 'invalid Bash command');
|
|
323
|
-
const executable = parsed.tokens[0] ?? '';
|
|
324
|
-
if (WRAPPER_COMMANDS.has(basename(executable))) {
|
|
325
|
-
return reject(
|
|
326
|
-
`shell wrapper "${executable}" is not allowed in restricted mode`,
|
|
327
|
-
);
|
|
328
|
-
}
|
|
329
|
-
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(executable)) {
|
|
330
|
-
return reject('environment assignments are not allowed in restricted mode');
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
if (permission.mode === 'read-only') {
|
|
334
|
-
return authorizeReadOnly(parsed.tokens);
|
|
335
|
-
}
|
|
336
|
-
const rule = permission.allow.find((candidate) =>
|
|
337
|
-
matchesRule(parsed.tokens ?? [], candidate),
|
|
338
|
-
);
|
|
339
|
-
if (!rule) {
|
|
340
|
-
return reject(`command does not match this step's Bash allow-list`);
|
|
341
|
-
}
|
|
342
|
-
if (usesReadOnlyPreset(parsed.tokens)) {
|
|
343
|
-
return authorizeReadOnly(parsed.tokens);
|
|
344
|
-
}
|
|
345
|
-
return authorizeHostedApiRead(parsed.tokens);
|
|
346
|
-
}
|
|
1
|
+
export { authorizeBash } from './bash-authorization.ts';
|
|
2
|
+
export type { BashAuthorization, RestrictedGitCommand } from './bash-types.ts';
|
|
3
|
+
export { tokenizeRestrictedCommand } from './restricted-command.ts';
|
|
4
|
+
export { parseRestrictedGitCommand } from './restricted-git.ts';
|
|
@@ -1,28 +1,33 @@
|
|
|
1
|
-
|
|
2
|
-
type: 'toolCall';
|
|
3
|
-
id: string;
|
|
4
|
-
name: string;
|
|
5
|
-
}
|
|
1
|
+
type ToolCallContent = {
|
|
2
|
+
readonly type: 'toolCall';
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly name: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
8
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
if (message
|
|
9
|
-
|
|
10
|
-
if (candidate.role !== 'assistant' || !Array.isArray(candidate.content))
|
|
10
|
+
const toolCalls = (message: unknown): Array<ToolCallContent> => {
|
|
11
|
+
if (!isRecord(message)) return [];
|
|
12
|
+
if (message.role !== 'assistant' || !Array.isArray(message.content))
|
|
11
13
|
return [];
|
|
12
14
|
|
|
13
|
-
return
|
|
15
|
+
return message.content.filter(
|
|
14
16
|
(item): item is ToolCallContent =>
|
|
15
|
-
item
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
typeof
|
|
19
|
-
typeof (item as { name?: unknown }).name === 'string',
|
|
17
|
+
isRecord(item) &&
|
|
18
|
+
item.type === 'toolCall' &&
|
|
19
|
+
typeof item.id === 'string' &&
|
|
20
|
+
typeof item.name === 'string',
|
|
20
21
|
);
|
|
21
|
-
}
|
|
22
|
+
};
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* A completion changes the active step and its permissions. It is therefore
|
|
25
26
|
* safe only when it is the sole tool call in an assistant message.
|
|
27
|
+
*
|
|
28
|
+
* @param message - Assistant message to validate.
|
|
29
|
+
* @param completionTool - Workflow completion tool name.
|
|
30
|
+
* @returns IDs of invalid completion calls.
|
|
26
31
|
*/
|
|
27
32
|
export function invalidCompletionCallIds(
|
|
28
33
|
message: unknown,
|
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
* Pi intentionally lets later extensions mutate tool inputs. Once this harness
|
|
3
3
|
* authorizes a workflow call, freeze the validated argument graph so a later
|
|
4
4
|
* handler cannot change what the tool will execute.
|
|
5
|
+
*
|
|
6
|
+
* @param input - Validated tool input to freeze recursively.
|
|
7
|
+
* @returns The same input after it has been frozen.
|
|
5
8
|
*/
|
|
6
|
-
export function freezeToolInput<
|
|
7
|
-
const seen = new WeakSet
|
|
9
|
+
export function freezeToolInput<TInput extends object>(input: TInput): TInput {
|
|
10
|
+
const seen = new WeakSet();
|
|
8
11
|
|
|
9
12
|
const freeze = (value: unknown): void => {
|
|
10
13
|
if (value === null || typeof value !== 'object' || seen.has(value)) return;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ToolAuthorization } from './tool-types.ts';
|
|
2
|
+
|
|
3
|
+
const UNSUPPORTED_MCP_PROXY_FIELDS = [
|
|
4
|
+
'action',
|
|
5
|
+
'connect',
|
|
6
|
+
'describe',
|
|
7
|
+
'search',
|
|
8
|
+
'regex',
|
|
9
|
+
'includeSchemas',
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
const reject = (reason: string): ToolAuthorization => ({
|
|
13
|
+
allowed: false,
|
|
14
|
+
reason,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const selectorAllows = (
|
|
18
|
+
selectors: ReadonlyArray<string>,
|
|
19
|
+
server: string,
|
|
20
|
+
tool: string,
|
|
21
|
+
): boolean =>
|
|
22
|
+
selectors.some((selector) => {
|
|
23
|
+
const separator = selector.indexOf('/');
|
|
24
|
+
if (separator === -1) return selector === server;
|
|
25
|
+
return (
|
|
26
|
+
selector.slice(0, separator) === server &&
|
|
27
|
+
selector.slice(separator + 1) === tool
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Authorizes an explicit MCP proxy server and tool selection.
|
|
33
|
+
*
|
|
34
|
+
* Discovery and other broad proxy modes are disabled so selectors cannot be
|
|
35
|
+
* widened at runtime.
|
|
36
|
+
*
|
|
37
|
+
* @param input - MCP proxy input.
|
|
38
|
+
* @param selectors - Allowed `server` or `server/tool` selectors.
|
|
39
|
+
* @returns The authorization decision.
|
|
40
|
+
*/
|
|
41
|
+
export const authorizeMcpProxy = (
|
|
42
|
+
input: Readonly<Record<string, unknown>>,
|
|
43
|
+
selectors: ReadonlyArray<string>,
|
|
44
|
+
): ToolAuthorization => {
|
|
45
|
+
if (selectors.length === 0) {
|
|
46
|
+
return reject('MCP access is disabled for this workflow step');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const unsupportedMode = UNSUPPORTED_MCP_PROXY_FIELDS.find(
|
|
50
|
+
(field) => input[field] !== undefined,
|
|
51
|
+
);
|
|
52
|
+
if (unsupportedMode) {
|
|
53
|
+
return reject(
|
|
54
|
+
`MCP proxy mode "${unsupportedMode}" is disabled; use an explicit server and tool`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (typeof input.server !== 'string' || !input.server.trim()) {
|
|
58
|
+
return reject('MCP proxy calls must name an explicit server');
|
|
59
|
+
}
|
|
60
|
+
if (typeof input.tool !== 'string' || !input.tool.trim()) {
|
|
61
|
+
return reject('MCP proxy calls must name an explicit tool');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const server = input.server.trim();
|
|
65
|
+
const tool = input.tool.trim();
|
|
66
|
+
return selectorAllows(selectors, server, tool)
|
|
67
|
+
? { allowed: true }
|
|
68
|
+
: reject(
|
|
69
|
+
`MCP tool "${server}/${tool}" is not allowed for this workflow step`,
|
|
70
|
+
);
|
|
71
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { RestrictedCommandTokens } from './bash-types.ts';
|
|
2
|
+
|
|
3
|
+
const UNQUOTED_SHELL_METACHARACTERS: ReadonlySet<string> = new Set([
|
|
4
|
+
';',
|
|
5
|
+
'&',
|
|
6
|
+
'|',
|
|
7
|
+
'<',
|
|
8
|
+
'>',
|
|
9
|
+
'\n',
|
|
10
|
+
'\r',
|
|
11
|
+
'`',
|
|
12
|
+
'$',
|
|
13
|
+
'(',
|
|
14
|
+
')',
|
|
15
|
+
'{',
|
|
16
|
+
'}',
|
|
17
|
+
'#',
|
|
18
|
+
'\0',
|
|
19
|
+
]);
|
|
20
|
+
const PATHNAME_EXPANSION_CHARACTERS: ReadonlySet<string> = new Set([
|
|
21
|
+
'*',
|
|
22
|
+
'?',
|
|
23
|
+
'[',
|
|
24
|
+
']',
|
|
25
|
+
'~',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
type Quote = "'" | '"';
|
|
29
|
+
|
|
30
|
+
const invalidCharacter = (character: string): string | undefined => {
|
|
31
|
+
if (character === '\n' || character === '\r' || character === '\0') {
|
|
32
|
+
return 'multiline and null characters are not allowed';
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Tokenizes the deliberately small shell subset accepted by restricted modes.
|
|
39
|
+
*
|
|
40
|
+
* Shell operators, substitutions, expansions, and comments are rejected
|
|
41
|
+
* before authorization rules are evaluated.
|
|
42
|
+
*
|
|
43
|
+
* @param command - Command text to tokenize.
|
|
44
|
+
* @returns Parsed tokens or a validation error.
|
|
45
|
+
*/
|
|
46
|
+
export const tokenizeRestrictedCommand = (
|
|
47
|
+
command: string,
|
|
48
|
+
): RestrictedCommandTokens => {
|
|
49
|
+
if (!command.trim()) return { error: 'empty Bash command' };
|
|
50
|
+
|
|
51
|
+
const tokens: Array<string> = [];
|
|
52
|
+
let token = '';
|
|
53
|
+
let quote: Quote | undefined;
|
|
54
|
+
let isEscaping = false;
|
|
55
|
+
let isTokenStarted = false;
|
|
56
|
+
|
|
57
|
+
for (const character of command) {
|
|
58
|
+
if (quote === "'") {
|
|
59
|
+
const error = invalidCharacter(character);
|
|
60
|
+
if (error) return { error };
|
|
61
|
+
|
|
62
|
+
if (character === "'") {
|
|
63
|
+
quote = undefined;
|
|
64
|
+
} else {
|
|
65
|
+
token += character;
|
|
66
|
+
}
|
|
67
|
+
isTokenStarted = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (quote === '"') {
|
|
72
|
+
const error = invalidCharacter(character);
|
|
73
|
+
if (error) return { error };
|
|
74
|
+
|
|
75
|
+
if (character === '"') {
|
|
76
|
+
quote = undefined;
|
|
77
|
+
} else if (character === '$' || character === '`' || character === '\\') {
|
|
78
|
+
return {
|
|
79
|
+
error:
|
|
80
|
+
'substitutions and escapes are not allowed inside double quotes',
|
|
81
|
+
};
|
|
82
|
+
} else {
|
|
83
|
+
token += character;
|
|
84
|
+
}
|
|
85
|
+
isTokenStarted = true;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (isEscaping) {
|
|
90
|
+
const error = invalidCharacter(character);
|
|
91
|
+
if (error) return { error };
|
|
92
|
+
|
|
93
|
+
token += character;
|
|
94
|
+
isEscaping = false;
|
|
95
|
+
isTokenStarted = true;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (character === '\\') {
|
|
100
|
+
isEscaping = true;
|
|
101
|
+
isTokenStarted = true;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (character === "'" || character === '"') {
|
|
106
|
+
quote = character;
|
|
107
|
+
isTokenStarted = true;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (UNQUOTED_SHELL_METACHARACTERS.has(character)) {
|
|
112
|
+
return {
|
|
113
|
+
error:
|
|
114
|
+
'shell operators, substitutions, expansions, and comments are not allowed',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (PATHNAME_EXPANSION_CHARACTERS.has(character)) {
|
|
119
|
+
return {
|
|
120
|
+
error: 'unquoted pathname and tilde expansion are not allowed',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (/\s/u.test(character)) {
|
|
125
|
+
if (isTokenStarted) {
|
|
126
|
+
tokens.push(token);
|
|
127
|
+
token = '';
|
|
128
|
+
isTokenStarted = false;
|
|
129
|
+
}
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
token += character;
|
|
134
|
+
isTokenStarted = true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (isEscaping) return { error: 'trailing Bash escape is not allowed' };
|
|
138
|
+
if (quote) return { error: 'unterminated Bash quote' };
|
|
139
|
+
if (isTokenStarted) tokens.push(token);
|
|
140
|
+
return tokens.length > 0 ? { tokens } : { error: 'empty Bash command' };
|
|
141
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { RestrictedGitCommand } from './bash-types.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Finds the Git subcommand in a tokenized restricted command.
|
|
5
|
+
*
|
|
6
|
+
* Only the global options deliberately supported by the policy are skipped.
|
|
7
|
+
*
|
|
8
|
+
* @param tokens - Tokenized Git command.
|
|
9
|
+
* @returns The subcommand and its index, or `undefined` for an invalid shape.
|
|
10
|
+
*/
|
|
11
|
+
export const parseRestrictedGitCommand = (
|
|
12
|
+
tokens: ReadonlyArray<string>,
|
|
13
|
+
): RestrictedGitCommand | undefined => {
|
|
14
|
+
let index = 1;
|
|
15
|
+
while (index < tokens.length) {
|
|
16
|
+
const token = tokens[index];
|
|
17
|
+
if (token === '-C') {
|
|
18
|
+
if (!tokens[index + 1]) return undefined;
|
|
19
|
+
index += 2;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (token === '--no-pager') {
|
|
23
|
+
index += 1;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (!token || token.startsWith('-')) return undefined;
|
|
27
|
+
return { subcommand: token, subcommandIndex: index };
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
};
|