@wichayutdew/pi-workflows 0.1.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/LICENSE +201 -0
- package/README.md +752 -0
- package/agents/step.md +17 -0
- package/dist/index.js +4576 -0
- package/examples/mr-comments.workflow.yaml +115 -0
- package/examples/prompts/mr-comments/implement.md +8 -0
- package/examples/prompts/mr-comments/inspect.md +5 -0
- package/examples/prompts/mr-comments/plan.md +13 -0
- package/examples/prompts/mr-comments/verify.md +7 -0
- package/examples/settings.yaml +19 -0
- package/package.json +81 -0
- package/schemas/settings.schema.json +22 -0
- package/schemas/workflow.schema.json +585 -0
- package/src/command-names.ts +46 -0
- package/src/commands.ts +80 -0
- package/src/config/ceiling.ts +153 -0
- package/src/config/command-conflicts.ts +31 -0
- package/src/config/load.ts +327 -0
- package/src/config/types.ts +187 -0
- package/src/config/validate.ts +1145 -0
- package/src/digest.ts +23 -0
- package/src/engine/checkpoint.ts +30 -0
- package/src/engine/resume.ts +44 -0
- package/src/engine/state.ts +186 -0
- package/src/engine/transitions.ts +426 -0
- package/src/harness.ts +1676 -0
- package/src/index.ts +15 -0
- package/src/integrations/plannotator.ts +235 -0
- package/src/integrations/prompt-gate.ts +54 -0
- package/src/integrations/subagents/child-runtime.ts +306 -0
- package/src/integrations/subagents/client.ts +239 -0
- package/src/integrations/subagents/protocol.ts +304 -0
- package/src/policy/approved-commands.ts +225 -0
- package/src/policy/bash.ts +355 -0
- package/src/policy/completion-batch.ts +36 -0
- package/src/policy/immutable-input.ts +18 -0
- package/src/policy/tools.ts +150 -0
- package/src/preflight.ts +76 -0
- package/src/prompt.ts +146 -0
- package/src/runtime/completion-tool.ts +22 -0
- package/src/runtime/main-step-runtime.ts +227 -0
- package/src/runtime/serial-task-queue.ts +17 -0
- package/src/runtime/step-result.ts +85 -0
- package/src/workflow-list.ts +25 -0
- package/src/workflow-status.ts +611 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import type { BashApprovalSource } from '../config/types.ts';
|
|
3
|
+
import {
|
|
4
|
+
parseRestrictedGitCommand,
|
|
5
|
+
tokenizeRestrictedCommand,
|
|
6
|
+
} from './bash.ts';
|
|
7
|
+
|
|
8
|
+
const SHELL_WRAPPERS = new Set([
|
|
9
|
+
'bash',
|
|
10
|
+
'env',
|
|
11
|
+
'exec',
|
|
12
|
+
'fish',
|
|
13
|
+
'sh',
|
|
14
|
+
'xargs',
|
|
15
|
+
'zsh',
|
|
16
|
+
]);
|
|
17
|
+
const REMOTE_EXECUTABLES = new Set(['curl', 'scp', 'ssh', 'rsync', 'wget']);
|
|
18
|
+
const FORBIDDEN_LONG_PUSH_OPTIONS = [
|
|
19
|
+
'--force',
|
|
20
|
+
'--force-if-includes',
|
|
21
|
+
'--force-with-lease',
|
|
22
|
+
'--all',
|
|
23
|
+
'--delete',
|
|
24
|
+
'--mirror',
|
|
25
|
+
'--prune',
|
|
26
|
+
'--tags',
|
|
27
|
+
] as const;
|
|
28
|
+
const PUBLISH_EXECUTABLES = new Set(['bun', 'cargo', 'npm', 'pnpm', 'yarn']);
|
|
29
|
+
const LOCAL_VERIFICATION_GIT_SUBCOMMANDS = new Set([
|
|
30
|
+
'add',
|
|
31
|
+
'branch',
|
|
32
|
+
'commit',
|
|
33
|
+
'diff',
|
|
34
|
+
'grep',
|
|
35
|
+
'log',
|
|
36
|
+
'ls-files',
|
|
37
|
+
'rev-parse',
|
|
38
|
+
'show',
|
|
39
|
+
'status',
|
|
40
|
+
'worktree',
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
function hasDestructivePushShortOption(token: string): boolean {
|
|
44
|
+
return (
|
|
45
|
+
token.startsWith('-') &&
|
|
46
|
+
!token.startsWith('--') &&
|
|
47
|
+
(token.slice(1).includes('f') || token.slice(1).includes('d'))
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function hasForbiddenLongPushOption(token: string): boolean {
|
|
52
|
+
if (!token.startsWith('--') || token === '--') return false;
|
|
53
|
+
const optionName = token.split('=', 1)[0] ?? token;
|
|
54
|
+
return FORBIDDEN_LONG_PUSH_OPTIONS.some((option) =>
|
|
55
|
+
option.startsWith(optionName),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function hasEmptyPushRefspecSide(token: string): boolean {
|
|
60
|
+
const separator = token.indexOf(':');
|
|
61
|
+
return separator >= 0 && (separator === 0 || separator === token.length - 1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
65
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseJsonDocuments(text: string): unknown[] {
|
|
69
|
+
const documents: unknown[] = [];
|
|
70
|
+
const trimmed = text.trim();
|
|
71
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
72
|
+
try {
|
|
73
|
+
documents.push(JSON.parse(trimmed));
|
|
74
|
+
} catch {
|
|
75
|
+
// Markdown artifacts are normally handled by fenced JSON below.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const fences = /```(?:json)?[ \t]*\r?\n([\s\S]*?)```/gi;
|
|
80
|
+
for (const match of text.matchAll(fences)) {
|
|
81
|
+
const candidate = match[1]?.trim();
|
|
82
|
+
if (!candidate) continue;
|
|
83
|
+
try {
|
|
84
|
+
documents.push(JSON.parse(candidate));
|
|
85
|
+
} catch {
|
|
86
|
+
// Other fenced examples are not approval contracts.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return documents;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function verificationCommands(
|
|
93
|
+
value: unknown,
|
|
94
|
+
role: 'worker' | 'reviewer',
|
|
95
|
+
): string[] {
|
|
96
|
+
if (!isObject(value) || !Array.isArray(value.repositories)) return [];
|
|
97
|
+
const commands: string[] = [];
|
|
98
|
+
for (const repository of value.repositories) {
|
|
99
|
+
if (!isObject(repository) || !Array.isArray(repository[role])) continue;
|
|
100
|
+
for (const check of repository[role]) {
|
|
101
|
+
if (isObject(check) && typeof check.command === 'string') {
|
|
102
|
+
commands.push(check.command);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return commands;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function remoteActionCommands(value: unknown): string[] {
|
|
110
|
+
if (!isObject(value) || !Array.isArray(value.actions)) return [];
|
|
111
|
+
const commands: string[] = [];
|
|
112
|
+
for (const action of value.actions) {
|
|
113
|
+
if (
|
|
114
|
+
isObject(action) &&
|
|
115
|
+
action.toolName === 'bash' &&
|
|
116
|
+
isObject(action.input) &&
|
|
117
|
+
typeof action.input.command === 'string'
|
|
118
|
+
) {
|
|
119
|
+
commands.push(action.input.command);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return commands;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function containsPublishOperation(tokens: readonly string[]): boolean {
|
|
126
|
+
return tokens
|
|
127
|
+
.slice(1)
|
|
128
|
+
.some((token) => token.length >= 3 && 'publish'.startsWith(token));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function safeVerificationCommand(command: string): boolean {
|
|
132
|
+
const parsed = tokenizeRestrictedCommand(command);
|
|
133
|
+
if (!parsed.tokens) return false;
|
|
134
|
+
const executable = basename(parsed.tokens[0] ?? '');
|
|
135
|
+
if (SHELL_WRAPPERS.has(executable) || REMOTE_EXECUTABLES.has(executable)) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
const subcommand =
|
|
139
|
+
executable === 'git'
|
|
140
|
+
? parseRestrictedGitCommand(parsed.tokens)?.subcommand
|
|
141
|
+
: parsed.tokens[1];
|
|
142
|
+
if (
|
|
143
|
+
executable === 'git' &&
|
|
144
|
+
(!subcommand || !LOCAL_VERIFICATION_GIT_SUBCOMMANDS.has(subcommand))
|
|
145
|
+
) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
if (executable === 'gh' || executable === 'glab') return false;
|
|
149
|
+
if (
|
|
150
|
+
PUBLISH_EXECUTABLES.has(executable) &&
|
|
151
|
+
containsPublishOperation(parsed.tokens)
|
|
152
|
+
) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
if (executable === 'docker' && parsed.tokens.slice(1).includes('push')) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function safeRemoteActionCommand(command: string): boolean {
|
|
162
|
+
const parsed = tokenizeRestrictedCommand(command);
|
|
163
|
+
if (!parsed.tokens) return false;
|
|
164
|
+
const executable = parsed.tokens[0];
|
|
165
|
+
const subcommand =
|
|
166
|
+
executable === 'git'
|
|
167
|
+
? parseRestrictedGitCommand(parsed.tokens)?.subcommand
|
|
168
|
+
: parsed.tokens[1];
|
|
169
|
+
if (executable === 'gh' || executable === 'glab') {
|
|
170
|
+
if (subcommand !== 'api') return false;
|
|
171
|
+
return !parsed.tokens.slice(2).some((token, index, apiTokens) => {
|
|
172
|
+
const upper = token.toUpperCase();
|
|
173
|
+
return (
|
|
174
|
+
upper === '--METHOD=DELETE' ||
|
|
175
|
+
upper === '-XDELETE' ||
|
|
176
|
+
((upper === '--METHOD' || upper === '-X') &&
|
|
177
|
+
apiTokens[index + 1]?.toUpperCase() === 'DELETE')
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
if (executable !== 'git' || subcommand !== 'push') return false;
|
|
182
|
+
return !parsed.tokens
|
|
183
|
+
.slice(1)
|
|
184
|
+
.some(
|
|
185
|
+
(token) =>
|
|
186
|
+
hasForbiddenLongPushOption(token) ||
|
|
187
|
+
token.startsWith('+') ||
|
|
188
|
+
hasEmptyPushRefspecSide(token) ||
|
|
189
|
+
hasDestructivePushShortOption(token),
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Extract exact Bash capabilities from machine-readable JSON that a
|
|
195
|
+
* human review gate already displayed and approved.
|
|
196
|
+
*/
|
|
197
|
+
export function extractApprovedBashCommands(
|
|
198
|
+
artifact: string,
|
|
199
|
+
sources: readonly BashApprovalSource[],
|
|
200
|
+
): string[] {
|
|
201
|
+
if (!artifact.trim() || sources.length === 0) return [];
|
|
202
|
+
const commands: string[] = [];
|
|
203
|
+
for (const document of parseJsonDocuments(artifact)) {
|
|
204
|
+
for (const source of sources) {
|
|
205
|
+
if (source === 'verification-worker') {
|
|
206
|
+
commands.push(
|
|
207
|
+
...verificationCommands(document, 'worker').filter(
|
|
208
|
+
safeVerificationCommand,
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
} else if (source === 'verification-reviewer') {
|
|
212
|
+
commands.push(
|
|
213
|
+
...verificationCommands(document, 'reviewer').filter(
|
|
214
|
+
safeVerificationCommand,
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
} else {
|
|
218
|
+
commands.push(
|
|
219
|
+
...remoteActionCommands(document).filter(safeRemoteActionCommand),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return [...new Set(commands)];
|
|
225
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import type { BashPermission, BashRule } from '../config/types.ts';
|
|
3
|
+
|
|
4
|
+
const UNQUOTED_SHELL_META = new Set([
|
|
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 (quote) {
|
|
147
|
+
if (character === quote) {
|
|
148
|
+
quote = undefined;
|
|
149
|
+
} else {
|
|
150
|
+
token += character;
|
|
151
|
+
}
|
|
152
|
+
tokenStarted = true;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (character === "'" || character === '"') {
|
|
156
|
+
quote = character;
|
|
157
|
+
tokenStarted = true;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (UNQUOTED_SHELL_META.has(character)) {
|
|
161
|
+
return {
|
|
162
|
+
error:
|
|
163
|
+
'shell operators, substitutions, expansions, and comments are not allowed',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (PATHNAME_EXPANSION.has(character)) {
|
|
167
|
+
return {
|
|
168
|
+
error: 'unquoted pathname and tilde expansion are not allowed',
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (/\s/.test(character)) {
|
|
172
|
+
if (tokenStarted) {
|
|
173
|
+
tokens.push(token);
|
|
174
|
+
token = '';
|
|
175
|
+
tokenStarted = false;
|
|
176
|
+
}
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
token += character;
|
|
180
|
+
tokenStarted = true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (escaping) return { error: 'trailing Bash escape is not allowed' };
|
|
184
|
+
if (quote) return { error: 'unterminated Bash quote' };
|
|
185
|
+
if (tokenStarted) tokens.push(token);
|
|
186
|
+
if (tokens.length === 0) return { error: 'empty Bash command' };
|
|
187
|
+
return { tokens };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface ValidationTokens {
|
|
191
|
+
tokens?: string[];
|
|
192
|
+
error?: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface RestrictedGitCommand {
|
|
196
|
+
subcommand: string;
|
|
197
|
+
subcommandIndex: number;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function parseRestrictedGitCommand(
|
|
201
|
+
tokens: readonly string[],
|
|
202
|
+
): RestrictedGitCommand | undefined {
|
|
203
|
+
let index = 1;
|
|
204
|
+
while (index < tokens.length) {
|
|
205
|
+
const token = tokens[index];
|
|
206
|
+
if (token === '-C') {
|
|
207
|
+
if (!tokens[index + 1]) return undefined;
|
|
208
|
+
index += 2;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (token === '--no-pager') {
|
|
212
|
+
index += 1;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (!token || token.startsWith('-')) return undefined;
|
|
216
|
+
return { subcommand: token, subcommandIndex: index };
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function matchesRule(tokens: readonly string[], rule: BashRule): boolean {
|
|
222
|
+
if (tokens[0] !== rule.executable) return false;
|
|
223
|
+
return rule.argsPrefix.every(
|
|
224
|
+
(expected, index) => tokens[index + 1] === expected,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function hasOption(tokens: readonly string[], option: string): boolean {
|
|
229
|
+
return tokens.some(
|
|
230
|
+
(token) => token === option || token.startsWith(`${option}=`),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function authorizeReadOnly(tokens: readonly string[]): BashAuthorization {
|
|
235
|
+
const executable = tokens[0] ?? '';
|
|
236
|
+
if (READ_ONLY_EXECUTABLES.has(executable)) {
|
|
237
|
+
if (
|
|
238
|
+
executable === 'rg' &&
|
|
239
|
+
(hasOption(tokens, '--pre') || hasOption(tokens, '--pre-glob'))
|
|
240
|
+
) {
|
|
241
|
+
return reject('rg preprocessors are not allowed in read-only mode');
|
|
242
|
+
}
|
|
243
|
+
return { allowed: true, tokens: [...tokens] };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (executable !== 'git') {
|
|
247
|
+
return reject(`"${executable}" is not in the read-only Bash preset`);
|
|
248
|
+
}
|
|
249
|
+
const gitCommand = parseRestrictedGitCommand(tokens);
|
|
250
|
+
if (!gitCommand || !READ_ONLY_GIT_SUBCOMMANDS.has(gitCommand.subcommand)) {
|
|
251
|
+
return reject(
|
|
252
|
+
`git subcommand "${gitCommand?.subcommand ?? ''}" is not read-only`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const dangerousOption = DANGEROUS_GIT_OPTIONS.find((option) =>
|
|
256
|
+
hasOption(tokens, option),
|
|
257
|
+
);
|
|
258
|
+
if (dangerousOption) {
|
|
259
|
+
return reject(
|
|
260
|
+
`git option "${dangerousOption}" is not allowed in read-only mode`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
const dangerousShortOption = DANGEROUS_GIT_SHORT_OPTIONS.find(
|
|
264
|
+
({ subcommand: matchedSubcommand, option }) =>
|
|
265
|
+
gitCommand.subcommand === matchedSubcommand &&
|
|
266
|
+
tokens
|
|
267
|
+
.slice(gitCommand.subcommandIndex + 1)
|
|
268
|
+
.some((token) => token === option || token.startsWith(option)),
|
|
269
|
+
);
|
|
270
|
+
if (dangerousShortOption) {
|
|
271
|
+
return reject(
|
|
272
|
+
`git option "${dangerousShortOption.option}" is not allowed in read-only mode`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
return { allowed: true, tokens: [...tokens] };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function usesReadOnlyPreset(tokens: readonly string[]): boolean {
|
|
279
|
+
const executable = tokens[0] ?? '';
|
|
280
|
+
return (
|
|
281
|
+
READ_ONLY_EXECUTABLES.has(executable) ||
|
|
282
|
+
(executable === 'git' &&
|
|
283
|
+
READ_ONLY_GIT_SUBCOMMANDS.has(
|
|
284
|
+
parseRestrictedGitCommand(tokens)?.subcommand ?? '',
|
|
285
|
+
))
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function authorizeHostedApiRead(tokens: readonly string[]): BashAuthorization {
|
|
290
|
+
const executable = basename(tokens[0] ?? '');
|
|
291
|
+
if ((executable !== 'gh' && executable !== 'glab') || tokens[1] !== 'api') {
|
|
292
|
+
return { allowed: true, tokens: [...tokens] };
|
|
293
|
+
}
|
|
294
|
+
const mutationOption = HOSTED_API_MUTATION_OPTIONS.find((option) =>
|
|
295
|
+
tokens
|
|
296
|
+
.slice(2)
|
|
297
|
+
.some(
|
|
298
|
+
(token) =>
|
|
299
|
+
token === option ||
|
|
300
|
+
token.startsWith(`${option}=`) ||
|
|
301
|
+
(option.length === 2 && token.startsWith(option)),
|
|
302
|
+
),
|
|
303
|
+
);
|
|
304
|
+
if (mutationOption) {
|
|
305
|
+
return reject(
|
|
306
|
+
`${executable} api option "${mutationOption}" is not allowed by a static read-only rule`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
return { allowed: true, tokens: [...tokens] };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function authorizeBash(
|
|
313
|
+
command: string,
|
|
314
|
+
permission: BashPermission,
|
|
315
|
+
approvedCommands: readonly string[] = [],
|
|
316
|
+
): BashAuthorization {
|
|
317
|
+
if (
|
|
318
|
+
(permission.approvedSources?.length ?? 0) > 0 &&
|
|
319
|
+
approvedCommands.includes(command)
|
|
320
|
+
) {
|
|
321
|
+
return { allowed: true };
|
|
322
|
+
}
|
|
323
|
+
if (permission.mode === 'unrestricted') {
|
|
324
|
+
return { allowed: true };
|
|
325
|
+
}
|
|
326
|
+
if (permission.mode === 'deny') {
|
|
327
|
+
return reject('Bash is disabled for this workflow step');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const parsed = tokenizeRestrictedCommand(command);
|
|
331
|
+
if (!parsed.tokens) return reject(parsed.error ?? 'invalid Bash command');
|
|
332
|
+
const executable = parsed.tokens[0] ?? '';
|
|
333
|
+
if (WRAPPER_COMMANDS.has(basename(executable))) {
|
|
334
|
+
return reject(
|
|
335
|
+
`shell wrapper "${executable}" is not allowed in restricted mode`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(executable)) {
|
|
339
|
+
return reject('environment assignments are not allowed in restricted mode');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (permission.mode === 'read-only') {
|
|
343
|
+
return authorizeReadOnly(parsed.tokens);
|
|
344
|
+
}
|
|
345
|
+
const rule = permission.allow.find((candidate) =>
|
|
346
|
+
matchesRule(parsed.tokens ?? [], candidate),
|
|
347
|
+
);
|
|
348
|
+
if (!rule) {
|
|
349
|
+
return reject(`command does not match this step's Bash allow-list`);
|
|
350
|
+
}
|
|
351
|
+
if (usesReadOnlyPreset(parsed.tokens)) {
|
|
352
|
+
return authorizeReadOnly(parsed.tokens);
|
|
353
|
+
}
|
|
354
|
+
return authorizeHostedApiRead(parsed.tokens);
|
|
355
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
interface ToolCallContent {
|
|
2
|
+
type: 'toolCall';
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function toolCalls(message: unknown): ToolCallContent[] {
|
|
8
|
+
if (message === null || typeof message !== 'object') return [];
|
|
9
|
+
const candidate = message as { role?: unknown; content?: unknown };
|
|
10
|
+
if (candidate.role !== 'assistant' || !Array.isArray(candidate.content))
|
|
11
|
+
return [];
|
|
12
|
+
|
|
13
|
+
return candidate.content.filter(
|
|
14
|
+
(item): item is ToolCallContent =>
|
|
15
|
+
item !== null &&
|
|
16
|
+
typeof item === 'object' &&
|
|
17
|
+
(item as { type?: unknown }).type === 'toolCall' &&
|
|
18
|
+
typeof (item as { id?: unknown }).id === 'string' &&
|
|
19
|
+
typeof (item as { name?: unknown }).name === 'string',
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A completion changes the active step and its permissions. It is therefore
|
|
25
|
+
* safe only when it is the sole tool call in an assistant message.
|
|
26
|
+
*/
|
|
27
|
+
export function invalidCompletionCallIds(
|
|
28
|
+
message: unknown,
|
|
29
|
+
completionTool: string,
|
|
30
|
+
): Set<string> {
|
|
31
|
+
const calls = toolCalls(message);
|
|
32
|
+
if (calls.length === 1 && calls[0]?.name === completionTool) return new Set();
|
|
33
|
+
return new Set(
|
|
34
|
+
calls.filter((call) => call.name === completionTool).map((call) => call.id),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi intentionally lets later extensions mutate tool inputs. Once this harness
|
|
3
|
+
* authorizes a workflow call, freeze the validated argument graph so a later
|
|
4
|
+
* handler cannot change what the tool will execute.
|
|
5
|
+
*/
|
|
6
|
+
export function freezeToolInput<T extends object>(input: T): T {
|
|
7
|
+
const seen = new WeakSet<object>();
|
|
8
|
+
|
|
9
|
+
const freeze = (value: unknown): void => {
|
|
10
|
+
if (value === null || typeof value !== 'object' || seen.has(value)) return;
|
|
11
|
+
seen.add(value);
|
|
12
|
+
for (const child of Object.values(value)) freeze(child);
|
|
13
|
+
Object.freeze(value);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
freeze(input);
|
|
17
|
+
return input;
|
|
18
|
+
}
|