@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.
Files changed (144) hide show
  1. package/README.md +55 -39
  2. package/dist/index.js +5771 -4794
  3. package/examples/mr-comments.workflow.yaml +2 -0
  4. package/package.json +1 -1
  5. package/schemas/workflow.schema.json +10 -1
  6. package/src/command-names.ts +7 -1
  7. package/src/commands.ts +126 -52
  8. package/src/config/catalog.ts +162 -0
  9. package/src/config/ceiling.ts +120 -116
  10. package/src/config/command-conflicts.ts +4 -4
  11. package/src/config/diagnostics.ts +28 -0
  12. package/src/config/load-settings.ts +37 -0
  13. package/src/config/load-types.ts +53 -0
  14. package/src/config/load-workflows.ts +135 -0
  15. package/src/config/load.ts +43 -311
  16. package/src/config/types.ts +150 -137
  17. package/src/config/validate.ts +12 -1261
  18. package/src/config/validation/permissions.ts +312 -0
  19. package/src/config/validation/prompt.ts +21 -0
  20. package/src/config/validation/settings.ts +122 -0
  21. package/src/config/validation/shared.ts +129 -0
  22. package/src/config/validation/shortcut.ts +118 -0
  23. package/src/config/validation/step.ts +228 -0
  24. package/src/config/validation/subagent.ts +288 -0
  25. package/src/config/validation/workflow.ts +156 -0
  26. package/src/config/yaml.ts +29 -0
  27. package/src/digest.ts +38 -6
  28. package/src/engine/checkpoint.ts +13 -9
  29. package/src/engine/create-run.ts +43 -0
  30. package/src/engine/gate-transitions.ts +187 -0
  31. package/src/engine/reconciliation-history.ts +88 -0
  32. package/src/engine/resume.ts +19 -7
  33. package/src/engine/run-advance.ts +122 -0
  34. package/src/engine/run-lifecycle.ts +124 -0
  35. package/src/engine/run-reconciliation.ts +116 -0
  36. package/src/engine/run-validation.ts +129 -0
  37. package/src/engine/state-types.ts +66 -0
  38. package/src/engine/state.ts +10 -193
  39. package/src/engine/transition-helpers.ts +28 -0
  40. package/src/engine/transition-types.ts +8 -0
  41. package/src/engine/transitions.ts +17 -471
  42. package/src/harness/action-context.ts +181 -0
  43. package/src/harness/catalog.ts +45 -0
  44. package/src/harness/context-idle.ts +21 -0
  45. package/src/harness/core-actions.ts +299 -0
  46. package/src/harness/delegation-control-actions.ts +250 -0
  47. package/src/harness/delegation-failure.ts +215 -0
  48. package/src/harness/delegation-plan.ts +219 -0
  49. package/src/harness/delegation-recovery-validation.ts +159 -0
  50. package/src/harness/delegation-response-actions.ts +295 -0
  51. package/src/harness/delegation-retry-policy.ts +118 -0
  52. package/src/harness/dependencies.ts +165 -0
  53. package/src/harness/gate-submission-action.ts +154 -0
  54. package/src/harness/lifecycle-actions.ts +154 -0
  55. package/src/harness/pause-actions.ts +120 -0
  56. package/src/harness/plannotator-result-actions.ts +123 -0
  57. package/src/harness/prompt-gate-actions.ts +270 -0
  58. package/src/harness/resume-action.ts +261 -0
  59. package/src/harness/start-actions.ts +183 -0
  60. package/src/harness/status-actions.ts +191 -0
  61. package/src/harness/step-execution-actions.ts +283 -0
  62. package/src/harness/types.ts +76 -0
  63. package/src/harness.ts +236 -2303
  64. package/src/index.ts +73 -11
  65. package/src/integrations/plannotator-requests.ts +127 -0
  66. package/src/integrations/plannotator-responses.ts +141 -0
  67. package/src/integrations/plannotator-types.ts +44 -0
  68. package/src/integrations/plannotator.ts +16 -235
  69. package/src/integrations/prompt-gate.ts +17 -10
  70. package/src/integrations/subagents/child-policy-envelope.ts +119 -0
  71. package/src/integrations/subagents/child-policy-paths.ts +66 -0
  72. package/src/integrations/subagents/child-policy-sections.ts +186 -0
  73. package/src/integrations/subagents/child-policy-types.ts +32 -0
  74. package/src/integrations/subagents/child-policy-validation.ts +163 -0
  75. package/src/integrations/subagents/child-runtime-completion.ts +56 -0
  76. package/src/integrations/subagents/child-runtime-dependencies.ts +47 -0
  77. package/src/integrations/subagents/child-runtime-files.ts +156 -0
  78. package/src/integrations/subagents/child-runtime-policy.ts +83 -0
  79. package/src/integrations/subagents/child-runtime-types.ts +30 -0
  80. package/src/integrations/subagents/child-runtime.ts +140 -301
  81. package/src/integrations/subagents/client-delegation.ts +181 -0
  82. package/src/integrations/subagents/client-messages.ts +66 -0
  83. package/src/integrations/subagents/client-types.ts +36 -0
  84. package/src/integrations/subagents/client.ts +127 -219
  85. package/src/integrations/subagents/delegated-result.ts +31 -0
  86. package/src/integrations/subagents/diagnostic-format.ts +40 -0
  87. package/src/integrations/subagents/diagnostic-text.ts +114 -0
  88. package/src/integrations/subagents/diagnostic-types.ts +77 -0
  89. package/src/integrations/subagents/diagnostics.ts +24 -977
  90. package/src/integrations/subagents/failure-correlation.ts +235 -0
  91. package/src/integrations/subagents/failure-transcript.ts +192 -0
  92. package/src/integrations/subagents/hidden-bash-failure.ts +98 -0
  93. package/src/integrations/subagents/protocol-events.ts +27 -0
  94. package/src/integrations/subagents/protocol.ts +30 -375
  95. package/src/integrations/subagents/replay-audit.ts +147 -0
  96. package/src/integrations/subagents/replay-safety.ts +82 -0
  97. package/src/integrations/subagents/session-diagnostics.ts +258 -0
  98. package/src/policy/approved-command-extraction.ts +71 -0
  99. package/src/policy/approved-commands.ts +15 -432
  100. package/src/policy/bash-authorization.ts +86 -0
  101. package/src/policy/bash-read-only.ts +161 -0
  102. package/src/policy/bash-types.ts +20 -0
  103. package/src/policy/bash.ts +4 -346
  104. package/src/policy/completion-batch.ts +21 -16
  105. package/src/policy/immutable-input.ts +5 -2
  106. package/src/policy/mcp-authorization.ts +71 -0
  107. package/src/policy/restricted-command.ts +141 -0
  108. package/src/policy/restricted-git.ts +30 -0
  109. package/src/policy/reviewed-artifact.ts +97 -0
  110. package/src/policy/reviewed-command-safety.ts +150 -0
  111. package/src/policy/reviewed-command-shape.ts +49 -0
  112. package/src/policy/reviewed-repository-contract.ts +138 -0
  113. package/src/policy/reviewed-repository-cwd.ts +102 -0
  114. package/src/policy/tool-call-authorization.ts +58 -0
  115. package/src/policy/tool-selection.ts +72 -0
  116. package/src/policy/tool-types.ts +14 -0
  117. package/src/policy/tools.ts +11 -150
  118. package/src/preflight.ts +96 -61
  119. package/src/prompt/main-workflow-notice.ts +41 -0
  120. package/src/prompt/retry-task.ts +83 -0
  121. package/src/prompt/step-contract.ts +101 -0
  122. package/src/prompt/step-sections.ts +75 -0
  123. package/src/prompt/step-task.ts +156 -0
  124. package/src/prompt/template.ts +83 -0
  125. package/src/prompt.ts +10 -254
  126. package/src/runtime/completion-tool.ts +3 -0
  127. package/src/runtime/main-step-completion.ts +78 -0
  128. package/src/runtime/main-step-lifecycle.ts +37 -0
  129. package/src/runtime/main-step-policy.ts +84 -0
  130. package/src/runtime/main-step-runtime-types.ts +64 -0
  131. package/src/runtime/main-step-runtime.ts +129 -200
  132. package/src/runtime/main-step-state.ts +85 -0
  133. package/src/runtime/serial-task-queue.ts +48 -13
  134. package/src/runtime/step-result.ts +48 -27
  135. package/src/workflow-list.ts +15 -6
  136. package/src/workflow-status/format-status.ts +52 -0
  137. package/src/workflow-status/formatting.ts +178 -0
  138. package/src/workflow-status/layout.ts +109 -0
  139. package/src/workflow-status/render-board.ts +101 -0
  140. package/src/workflow-status/render-path.ts +123 -0
  141. package/src/workflow-status/render-summary.ts +144 -0
  142. package/src/workflow-status/types.ts +40 -0
  143. package/src/workflow-status/view.ts +273 -0
  144. package/src/workflow-status.ts +20 -820
@@ -1,432 +1,15 @@
1
- import { statSync } from 'node:fs';
2
- import { basename, isAbsolute } from 'node:path';
3
- import type { BashApprovalSource } from '../config/types.ts';
4
- import {
5
- parseRestrictedGitCommand,
6
- tokenizeRestrictedCommand,
7
- } from './bash.ts';
8
-
9
- const SHELL_WRAPPERS = new Set([
10
- 'bash',
11
- 'env',
12
- 'exec',
13
- 'fish',
14
- 'sh',
15
- 'xargs',
16
- 'zsh',
17
- ]);
18
- const REMOTE_EXECUTABLES = new Set(['curl', 'scp', 'ssh', 'rsync', 'wget']);
19
- const FORBIDDEN_LONG_PUSH_OPTIONS = [
20
- '--force',
21
- '--force-if-includes',
22
- '--force-with-lease',
23
- '--all',
24
- '--delete',
25
- '--mirror',
26
- '--prune',
27
- '--tags',
28
- ] as const;
29
- const PUBLISH_EXECUTABLES = new Set(['bun', 'cargo', 'npm', 'pnpm', 'yarn']);
30
- const LOCAL_VERIFICATION_GIT_SUBCOMMANDS = new Set([
31
- 'add',
32
- 'branch',
33
- 'commit',
34
- 'diff',
35
- 'grep',
36
- 'log',
37
- 'ls-files',
38
- 'rev-parse',
39
- 'show',
40
- 'status',
41
- 'worktree',
42
- ]);
43
-
44
- function hasDestructivePushShortOption(token: string): boolean {
45
- return (
46
- token.startsWith('-') &&
47
- !token.startsWith('--') &&
48
- (token.slice(1).includes('f') || token.slice(1).includes('d'))
49
- );
50
- }
51
-
52
- function hasForbiddenLongPushOption(token: string): boolean {
53
- if (!token.startsWith('--') || token === '--') return false;
54
- const optionName = token.split('=', 1)[0] ?? token;
55
- return FORBIDDEN_LONG_PUSH_OPTIONS.some((option) =>
56
- option.startsWith(optionName),
57
- );
58
- }
59
-
60
- function hasEmptyPushRefspecSide(token: string): boolean {
61
- const separator = token.indexOf(':');
62
- return separator >= 0 && (separator === 0 || separator === token.length - 1);
63
- }
64
-
65
- function isObject(value: unknown): value is Record<string, unknown> {
66
- return value !== null && typeof value === 'object' && !Array.isArray(value);
67
- }
68
-
69
- interface ParsedJsonDocuments {
70
- documents: unknown[];
71
- malformedCandidate: boolean;
72
- }
73
-
74
- function parseJsonDocumentsWithValidity(text: string): ParsedJsonDocuments {
75
- const documents: unknown[] = [];
76
- let malformedCandidate = false;
77
- const trimmed = text.trim();
78
- if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
79
- try {
80
- documents.push(JSON.parse(trimmed));
81
- } catch {
82
- malformedCandidate = true;
83
- }
84
- }
85
-
86
- const fences = /```(?:json)?[ \t]*\r?\n([\s\S]*?)```/gi;
87
- for (const match of text.matchAll(fences)) {
88
- const candidate = match[1]?.trim();
89
- if (!candidate) continue;
90
- try {
91
- documents.push(JSON.parse(candidate));
92
- } catch {
93
- malformedCandidate = true;
94
- }
95
- }
96
- return { documents, malformedCandidate };
97
- }
98
-
99
- function parseJsonDocuments(text: string): unknown[] {
100
- return parseJsonDocumentsWithValidity(text).documents;
101
- }
102
-
103
- function verificationCommands(
104
- value: unknown,
105
- role: 'worker' | 'reviewer',
106
- ): string[] {
107
- if (!isObject(value) || !Array.isArray(value.repositories)) return [];
108
- const commands: string[] = [];
109
- for (const repository of value.repositories) {
110
- if (!isObject(repository) || !Array.isArray(repository[role])) continue;
111
- for (const check of repository[role]) {
112
- if (isObject(check) && typeof check.command === 'string') {
113
- commands.push(check.command);
114
- }
115
- }
116
- }
117
- return commands;
118
- }
119
-
120
- function malformedBunInstallReason(command: string): string | undefined {
121
- const parsed = tokenizeRestrictedCommand(command);
122
- if (!parsed.tokens) return undefined;
123
- const executable = basename(parsed.tokens[0] ?? '');
124
- if (executable !== 'bun') return undefined;
125
-
126
- const installIndex = parsed.tokens.indexOf('install', 1);
127
- if (installIndex <= 1) return undefined;
128
- const optionsBeforeInstall = parsed.tokens.slice(1, installIndex);
129
- if (
130
- !optionsBeforeInstall.some(
131
- (token) => token === '--cwd' || token.startsWith('--cwd='),
132
- )
133
- ) {
134
- return undefined;
135
- }
136
-
137
- return [
138
- `Invalid Bun install command: ${JSON.stringify(command)}.`,
139
- '`--cwd` appears before `install`, so Bun interprets `install` as a package script.',
140
- 'Use `bun install --cwd <absolute-cwd> --frozen-lockfile`, preserving the reviewed path and any other intended install flags, then resubmit the plan.',
141
- ].join(' ');
142
- }
143
-
144
- /**
145
- * Reject known command-shape mistakes before a human is asked to approve an
146
- * execution contract. Agents still diagnose arbitrary runtime failures; these
147
- * deterministic checks prevent previously observed parser traps from escaping
148
- * into an approved handoff.
149
- */
150
- export function reviewedCommandShapeError(
151
- artifact: string,
152
- ): string | undefined {
153
- for (const document of parseJsonDocuments(artifact)) {
154
- for (const role of ['worker', 'reviewer'] as const) {
155
- for (const command of verificationCommands(document, role)) {
156
- const reason = malformedBunInstallReason(command);
157
- if (reason) return reason;
158
- }
159
- }
160
- }
161
- return undefined;
162
- }
163
-
164
- function remoteActionCommands(value: unknown): string[] {
165
- if (!isObject(value) || !Array.isArray(value.actions)) return [];
166
- const commands: string[] = [];
167
- for (const action of value.actions) {
168
- if (
169
- isObject(action) &&
170
- action.toolName === 'bash' &&
171
- isObject(action.input) &&
172
- typeof action.input.command === 'string'
173
- ) {
174
- commands.push(action.input.command);
175
- }
176
- }
177
- return commands;
178
- }
179
-
180
- export type ReviewedRepositoryCwdResolution =
181
- | { kind: 'none' }
182
- | { kind: 'invalid'; reason: string }
183
- | {
184
- kind: 'resolved';
185
- cwd: string;
186
- repositoryCwd: string;
187
- bootstrapping: boolean;
188
- };
189
-
190
- type DirectoryState = 'directory' | 'missing' | 'invalid';
191
-
192
- function directoryState(path: string): DirectoryState {
193
- try {
194
- return statSync(path).isDirectory() ? 'directory' : 'invalid';
195
- } catch (error) {
196
- const code = (error as { code?: unknown }).code;
197
- return code === 'ENOENT' || code === 'ENOTDIR' ? 'missing' : 'invalid';
198
- }
199
- }
200
-
201
- function invalidRepositoryCwd(reason: string): ReviewedRepositoryCwdResolution {
202
- return { kind: 'invalid', reason };
203
- }
204
-
205
- /**
206
- * Resolve a reviewed repository launch directory without silently falling
207
- * back when a repository contract is malformed, ambiguous, or incomplete.
208
- */
209
- export function resolveReviewedRepositoryCwd(
210
- artifact: string,
211
- ): ReviewedRepositoryCwdResolution {
212
- const parsed = parseJsonDocumentsWithValidity(artifact);
213
- const directories = new Set<string>();
214
- const sourceDirectories = new Set<string>();
215
- let hasRepositoryContract = false;
216
- for (const document of parsed.documents) {
217
- if (!isObject(document) || !('repositories' in document)) continue;
218
- hasRepositoryContract = true;
219
- if (
220
- !Array.isArray(document.repositories) ||
221
- document.repositories.length === 0
222
- ) {
223
- return invalidRepositoryCwd(
224
- 'Reviewed repository contract must contain a non-empty repositories array',
225
- );
226
- }
227
- for (const repository of document.repositories) {
228
- if (!isObject(repository)) {
229
- return invalidRepositoryCwd(
230
- 'Reviewed repository contract contains a malformed repository entry',
231
- );
232
- }
233
- if (
234
- typeof repository.cwd !== 'string' ||
235
- !isAbsolute(repository.cwd) ||
236
- repository.cwd.includes('\0')
237
- ) {
238
- return invalidRepositoryCwd(
239
- 'Reviewed repository contract repository cwd must be an absolute path',
240
- );
241
- }
242
- directories.add(repository.cwd);
243
- if ('sourceCwd' in repository) {
244
- if (
245
- typeof repository.sourceCwd !== 'string' ||
246
- !isAbsolute(repository.sourceCwd) ||
247
- repository.sourceCwd.includes('\0')
248
- ) {
249
- return invalidRepositoryCwd(
250
- 'Reviewed repository contract sourceCwd must be an absolute path',
251
- );
252
- }
253
- sourceDirectories.add(repository.sourceCwd);
254
- }
255
- }
256
- }
257
-
258
- if (!hasRepositoryContract) {
259
- return parsed.malformedCandidate
260
- ? invalidRepositoryCwd(
261
- 'Reviewed repository contract contains malformed JSON',
262
- )
263
- : { kind: 'none' };
264
- }
265
- if (parsed.malformedCandidate) {
266
- return invalidRepositoryCwd(
267
- 'Reviewed repository contract contains malformed JSON',
268
- );
269
- }
270
- if (directories.size !== 1) {
271
- return invalidRepositoryCwd(
272
- 'Reviewed repository contract is ambiguous: expected exactly one repository cwd',
273
- );
274
- }
275
- if (sourceDirectories.size > 1) {
276
- return invalidRepositoryCwd(
277
- 'Reviewed repository contract is ambiguous: expected at most one sourceCwd',
278
- );
279
- }
280
-
281
- const repositoryCwd = directories.values().next().value as string;
282
- const repositoryState = directoryState(repositoryCwd);
283
- if (repositoryState === 'directory') {
284
- return {
285
- kind: 'resolved',
286
- cwd: repositoryCwd,
287
- repositoryCwd,
288
- bootstrapping: false,
289
- };
290
- }
291
- if (repositoryState === 'invalid') {
292
- return invalidRepositoryCwd(
293
- `Reviewed repository cwd is not an accessible directory: ${repositoryCwd}`,
294
- );
295
- }
296
- if (sourceDirectories.size !== 1) {
297
- return invalidRepositoryCwd(
298
- 'Reviewed repository target is missing and requires exactly one absolute sourceCwd',
299
- );
300
- }
301
-
302
- const sourceCwd = sourceDirectories.values().next().value as string;
303
- if (directoryState(sourceCwd) !== 'directory') {
304
- return invalidRepositoryCwd(
305
- `Reviewed repository sourceCwd is not an existing directory: ${sourceCwd}`,
306
- );
307
- }
308
- return {
309
- kind: 'resolved',
310
- cwd: sourceCwd,
311
- repositoryCwd,
312
- bootstrapping: true,
313
- };
314
- }
315
-
316
- function containsPublishOperation(tokens: readonly string[]): boolean {
317
- return tokens
318
- .slice(1)
319
- .some((token) => token.length >= 3 && 'publish'.startsWith(token));
320
- }
321
-
322
- function safeVerificationCommand(command: string): boolean {
323
- const parsed = tokenizeRestrictedCommand(command);
324
- if (!parsed.tokens) return false;
325
- const executable = basename(parsed.tokens[0] ?? '');
326
- if (SHELL_WRAPPERS.has(executable) || REMOTE_EXECUTABLES.has(executable)) {
327
- return false;
328
- }
329
- const subcommand =
330
- executable === 'git'
331
- ? parseRestrictedGitCommand(parsed.tokens)?.subcommand
332
- : parsed.tokens[1];
333
- if (
334
- executable === 'git' &&
335
- (!subcommand || !LOCAL_VERIFICATION_GIT_SUBCOMMANDS.has(subcommand))
336
- ) {
337
- return false;
338
- }
339
- if (executable === 'gh' || executable === 'glab') return false;
340
- if (
341
- PUBLISH_EXECUTABLES.has(executable) &&
342
- containsPublishOperation(parsed.tokens)
343
- ) {
344
- return false;
345
- }
346
- if (executable === 'docker' && parsed.tokens.slice(1).includes('push')) {
347
- return false;
348
- }
349
- return true;
350
- }
351
-
352
- function safeRemoteActionCommand(command: string): boolean {
353
- const parsed = tokenizeRestrictedCommand(command);
354
- if (!parsed.tokens) return false;
355
- const executable = parsed.tokens[0];
356
- const subcommand =
357
- executable === 'git'
358
- ? parseRestrictedGitCommand(parsed.tokens)?.subcommand
359
- : parsed.tokens[1];
360
- if (executable === 'gh' || executable === 'glab') {
361
- if (subcommand !== 'api') return false;
362
- return !parsed.tokens.slice(2).some((token, index, apiTokens) => {
363
- const upper = token.toUpperCase();
364
- return (
365
- upper === '--METHOD=DELETE' ||
366
- upper === '-XDELETE' ||
367
- ((upper === '--METHOD' || upper === '-X') &&
368
- apiTokens[index + 1]?.toUpperCase() === 'DELETE')
369
- );
370
- });
371
- }
372
- if (executable !== 'git' || subcommand !== 'push') return false;
373
- return !parsed.tokens
374
- .slice(1)
375
- .some(
376
- (token) =>
377
- hasForbiddenLongPushOption(token) ||
378
- token.startsWith('+') ||
379
- hasEmptyPushRefspecSide(token) ||
380
- hasDestructivePushShortOption(token),
381
- );
382
- }
383
-
384
- /**
385
- * Extract exact Bash capabilities from machine-readable JSON that a
386
- * human review gate already displayed and approved.
387
- */
388
- export function extractApprovedBashCommands(
389
- artifact: string,
390
- sources: readonly BashApprovalSource[],
391
- ): string[] {
392
- if (!artifact.trim() || sources.length === 0) return [];
393
- const commands: string[] = [];
394
- for (const document of parseJsonDocuments(artifact)) {
395
- for (const source of sources) {
396
- if (source === 'verification-worker') {
397
- commands.push(
398
- ...verificationCommands(document, 'worker').filter(
399
- safeVerificationCommand,
400
- ),
401
- );
402
- } else if (source === 'verification-reviewer') {
403
- commands.push(
404
- ...verificationCommands(document, 'reviewer').filter(
405
- safeVerificationCommand,
406
- ),
407
- );
408
- } else {
409
- commands.push(
410
- ...remoteActionCommands(document).filter(safeRemoteActionCommand),
411
- );
412
- }
413
- }
414
- }
415
- return [...new Set(commands)];
416
- }
417
-
418
- /**
419
- * Keep only commands that occur in both the human-approved artifact and the
420
- * latest completed-step handoff. A child may narrow reviewed authority, but
421
- * its unreviewed output can never add a Bash capability.
422
- */
423
- export function narrowApprovedBashCommands(
424
- artifact: string,
425
- handoff: string,
426
- sources: readonly BashApprovalSource[],
427
- ): string[] {
428
- const approved = extractApprovedBashCommands(artifact, sources);
429
- if (approved.length === 0) return [];
430
- const retained = new Set(extractApprovedBashCommands(handoff, sources));
431
- return approved.filter((command) => retained.has(command));
432
- }
1
+ export {
2
+ extractApprovedBashCommands,
3
+ narrowApprovedBashCommands,
4
+ } from './approved-command-extraction.ts';
5
+ export { reviewedCommandShapeError } from './reviewed-command-shape.ts';
6
+ export {
7
+ createReviewedRepositoryCwdResolver,
8
+ resolveReviewedRepositoryCwd,
9
+ } from './reviewed-repository-cwd.ts';
10
+ export type {
11
+ DirectoryState,
12
+ ResolveReviewedRepositoryCwd,
13
+ ReviewedRepositoryCwdDependencies,
14
+ ReviewedRepositoryCwdResolution,
15
+ } from './reviewed-repository-cwd.ts';
@@ -0,0 +1,86 @@
1
+ import { basename } from 'node:path';
2
+ import type { BashPermission, BashRule } from '../config/types.ts';
3
+ import {
4
+ authorizeHostedApiRead,
5
+ authorizeReadOnlyBash,
6
+ usesReadOnlyBashPreset,
7
+ } from './bash-read-only.ts';
8
+ import type { BashAuthorization } from './bash-types.ts';
9
+ import { tokenizeRestrictedCommand } from './restricted-command.ts';
10
+
11
+ const SHELL_WRAPPERS: ReadonlySet<string> = new Set([
12
+ 'bash',
13
+ 'builtin',
14
+ 'command',
15
+ 'env',
16
+ 'exec',
17
+ 'fish',
18
+ 'sh',
19
+ 'time',
20
+ 'xargs',
21
+ 'zsh',
22
+ ]);
23
+ const ENVIRONMENT_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/u;
24
+
25
+ const reject = (reason: string): BashAuthorization => ({
26
+ allowed: false,
27
+ reason,
28
+ });
29
+
30
+ const matchesRule = (tokens: ReadonlyArray<string>, rule: BashRule): boolean =>
31
+ tokens[0] === rule.executable &&
32
+ rule.argsPrefix.every((expected, index) => tokens[index + 1] === expected);
33
+
34
+ /**
35
+ * Authorizes a Bash command for a workflow step.
36
+ *
37
+ * Exact reviewed commands are considered first, followed by the configured
38
+ * terminal mode and its static restrictions.
39
+ *
40
+ * @param command - Bash command text.
41
+ * @param permission - Bash permission configured for the active step.
42
+ * @param approvedCommands - Exact commands derived from a reviewed artifact.
43
+ * @returns The authorization decision.
44
+ */
45
+ export const authorizeBash = (
46
+ command: string,
47
+ permission: BashPermission,
48
+ approvedCommands: ReadonlyArray<string> = [],
49
+ ): BashAuthorization => {
50
+ const isReviewedCommand =
51
+ (permission.approvedSources?.length ?? 0) > 0 &&
52
+ approvedCommands.includes(command);
53
+ if (isReviewedCommand || permission.mode === 'unrestricted') {
54
+ return { allowed: true };
55
+ }
56
+ if (permission.mode === 'deny') {
57
+ return reject('Bash is disabled for this workflow step');
58
+ }
59
+
60
+ const parsed = tokenizeRestrictedCommand(command);
61
+ if (!parsed.tokens) return reject(parsed.error);
62
+
63
+ const executable = parsed.tokens[0] ?? '';
64
+ if (SHELL_WRAPPERS.has(basename(executable))) {
65
+ return reject(
66
+ `shell wrapper "${executable}" is not allowed in restricted mode`,
67
+ );
68
+ }
69
+ if (ENVIRONMENT_ASSIGNMENT.test(executable)) {
70
+ return reject('environment assignments are not allowed in restricted mode');
71
+ }
72
+
73
+ if (permission.mode === 'read-only') {
74
+ return authorizeReadOnlyBash(parsed.tokens);
75
+ }
76
+
77
+ const rule = permission.allow.find((candidate) =>
78
+ matchesRule(parsed.tokens, candidate),
79
+ );
80
+ if (!rule) {
81
+ return reject("command does not match this step's Bash allow-list");
82
+ }
83
+ return usesReadOnlyBashPreset(parsed.tokens)
84
+ ? authorizeReadOnlyBash(parsed.tokens)
85
+ : authorizeHostedApiRead(parsed.tokens);
86
+ };
@@ -0,0 +1,161 @@
1
+ import { basename } from 'node:path';
2
+ import type { BashAuthorization } from './bash-types.ts';
3
+ import { parseRestrictedGitCommand } from './restricted-git.ts';
4
+
5
+ const READ_ONLY_EXECUTABLES: ReadonlySet<string> = new Set([
6
+ 'grep',
7
+ 'head',
8
+ 'ls',
9
+ 'pwd',
10
+ 'rg',
11
+ 'stat',
12
+ 'tail',
13
+ 'wc',
14
+ ]);
15
+ const READ_ONLY_GIT_SUBCOMMANDS: ReadonlySet<string> = new Set([
16
+ 'diff',
17
+ 'grep',
18
+ 'log',
19
+ 'ls-files',
20
+ 'rev-parse',
21
+ 'show',
22
+ 'status',
23
+ ]);
24
+ const DANGEROUS_GIT_OPTIONS = [
25
+ '--config-env',
26
+ '--exec',
27
+ '--ext-diff',
28
+ '--open-files-in-pager',
29
+ '--output',
30
+ '--textconv',
31
+ ] as const;
32
+ const DANGEROUS_GIT_SHORT_OPTIONS = [
33
+ // `git grep -O<pager>` executes the supplied pager command.
34
+ { subcommand: 'grep', option: '-O' },
35
+ ] as const;
36
+ const HOSTED_API_MUTATION_OPTIONS = [
37
+ '--field',
38
+ '--form',
39
+ '--input',
40
+ '--method',
41
+ '--raw-field',
42
+ '-F',
43
+ '-X',
44
+ '-f',
45
+ ] as const;
46
+
47
+ const reject = (reason: string): BashAuthorization => ({
48
+ allowed: false,
49
+ reason,
50
+ });
51
+
52
+ const allow = (tokens: ReadonlyArray<string>): BashAuthorization => ({
53
+ allowed: true,
54
+ tokens: [...tokens],
55
+ });
56
+
57
+ const hasOption = (tokens: ReadonlyArray<string>, option: string): boolean =>
58
+ tokens.some((token) => token === option || token.startsWith(`${option}=`));
59
+
60
+ /**
61
+ * Authorizes a tokenized command against the static read-only Bash preset.
62
+ *
63
+ * @param tokens - Restricted command tokens.
64
+ * @returns The authorization decision.
65
+ */
66
+ export const authorizeReadOnlyBash = (
67
+ tokens: ReadonlyArray<string>,
68
+ ): BashAuthorization => {
69
+ const executable = tokens[0] ?? '';
70
+ if (READ_ONLY_EXECUTABLES.has(executable)) {
71
+ const hasRipgrepPreprocessor =
72
+ executable === 'rg' &&
73
+ (hasOption(tokens, '--pre') || hasOption(tokens, '--pre-glob'));
74
+ return hasRipgrepPreprocessor
75
+ ? reject('rg preprocessors are not allowed in read-only mode')
76
+ : allow(tokens);
77
+ }
78
+
79
+ if (executable !== 'git') {
80
+ return reject(`"${executable}" is not in the read-only Bash preset`);
81
+ }
82
+
83
+ const gitCommand = parseRestrictedGitCommand(tokens);
84
+ if (!gitCommand || !READ_ONLY_GIT_SUBCOMMANDS.has(gitCommand.subcommand)) {
85
+ return reject(
86
+ `git subcommand "${gitCommand?.subcommand ?? ''}" is not read-only`,
87
+ );
88
+ }
89
+
90
+ const dangerousOption = DANGEROUS_GIT_OPTIONS.find((option) =>
91
+ hasOption(tokens, option),
92
+ );
93
+ if (dangerousOption) {
94
+ return reject(
95
+ `git option "${dangerousOption}" is not allowed in read-only mode`,
96
+ );
97
+ }
98
+
99
+ const dangerousShortOption = DANGEROUS_GIT_SHORT_OPTIONS.find(
100
+ ({ subcommand, option }) =>
101
+ gitCommand.subcommand === subcommand &&
102
+ tokens
103
+ .slice(gitCommand.subcommandIndex + 1)
104
+ .some((token) => token === option || token.startsWith(option)),
105
+ );
106
+ return dangerousShortOption
107
+ ? reject(
108
+ `git option "${dangerousShortOption.option}" is not allowed in read-only mode`,
109
+ )
110
+ : allow(tokens);
111
+ };
112
+
113
+ /**
114
+ * Reports whether a tokenized command belongs to the read-only preset.
115
+ *
116
+ * @param tokens - Restricted command tokens.
117
+ * @returns `true` when the read-only preset owns the command.
118
+ */
119
+ export const usesReadOnlyBashPreset = (
120
+ tokens: ReadonlyArray<string>,
121
+ ): boolean => {
122
+ const executable = tokens[0] ?? '';
123
+ return (
124
+ READ_ONLY_EXECUTABLES.has(executable) ||
125
+ (executable === 'git' &&
126
+ READ_ONLY_GIT_SUBCOMMANDS.has(
127
+ parseRestrictedGitCommand(tokens)?.subcommand ?? '',
128
+ ))
129
+ );
130
+ };
131
+
132
+ /**
133
+ * Prevents mutation flags in allow-listed GitHub and GitLab API reads.
134
+ *
135
+ * @param tokens - Restricted command tokens.
136
+ * @returns The authorization decision.
137
+ */
138
+ export const authorizeHostedApiRead = (
139
+ tokens: ReadonlyArray<string>,
140
+ ): BashAuthorization => {
141
+ const executable = basename(tokens[0] ?? '');
142
+ if ((executable !== 'gh' && executable !== 'glab') || tokens[1] !== 'api') {
143
+ return allow(tokens);
144
+ }
145
+
146
+ const mutationOption = HOSTED_API_MUTATION_OPTIONS.find((option) =>
147
+ tokens
148
+ .slice(2)
149
+ .some(
150
+ (token) =>
151
+ token === option ||
152
+ token.startsWith(`${option}=`) ||
153
+ (option.length === 2 && token.startsWith(option)),
154
+ ),
155
+ );
156
+ return mutationOption
157
+ ? reject(
158
+ `${executable} api option "${mutationOption}" is not allowed by a static read-only rule`,
159
+ )
160
+ : allow(tokens);
161
+ };