@wichayutdew/pi-workflows 1.0.0 → 2.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.
Files changed (131) hide show
  1. package/README.md +312 -221
  2. package/agents/step.md +4 -11
  3. package/dist/index.js +2556 -961
  4. package/examples/mr-comments.workflow.yaml +18 -8
  5. package/examples/prompts/mr-comments/implement.md +13 -9
  6. package/examples/prompts/mr-comments/plan.md +44 -15
  7. package/examples/prompts/mr-comments/verify.md +8 -7
  8. package/examples/settings.yaml +7 -1
  9. package/examples/starter-kit/mr-comment.workflow.yaml +149 -0
  10. package/examples/starter-kit/mr-review.workflow.yaml +130 -0
  11. package/examples/starter-kit/steps/mr-comment/fetch.md +33 -0
  12. package/examples/starter-kit/steps/mr-comment/implement.md +38 -0
  13. package/examples/starter-kit/steps/mr-comment/plan.md +62 -0
  14. package/examples/starter-kit/steps/mr-comment/publish.md +36 -0
  15. package/examples/starter-kit/steps/mr-comment/verify.md +35 -0
  16. package/examples/starter-kit/steps/mr-review/fetch.md +25 -0
  17. package/examples/starter-kit/steps/mr-review/publish.md +30 -0
  18. package/examples/starter-kit/steps/mr-review/review.md +69 -0
  19. package/examples/starter-kit/steps/mr-review/verify.md +24 -0
  20. package/examples/starter-kit/steps/shared/prepare-workspace.md +57 -0
  21. package/examples/starter-kit/steps/ticket/implement.md +35 -0
  22. package/examples/starter-kit/steps/ticket/plan.md +40 -0
  23. package/examples/starter-kit/steps/ticket/verify.md +29 -0
  24. package/examples/starter-kit/steps/work/implement.md +34 -0
  25. package/examples/starter-kit/steps/work/plan.md +38 -0
  26. package/examples/starter-kit/steps/work/verify.md +30 -0
  27. package/examples/starter-kit/ticket.workflow.yaml +100 -0
  28. package/examples/starter-kit/work.workflow.yaml +97 -0
  29. package/package.json +3 -3
  30. package/schemas/workflow.schema.json +78 -35
  31. package/src/command-names.ts +1 -0
  32. package/src/commands.ts +33 -4
  33. package/src/config/ceiling.ts +6 -8
  34. package/src/config/load-workflows.ts +12 -1
  35. package/src/config/step-digests.ts +19 -0
  36. package/src/config/types.ts +17 -11
  37. package/src/config/validation/permissions.ts +5 -45
  38. package/src/config/validation/prompt.ts +3 -0
  39. package/src/config/validation/shared.ts +0 -2
  40. package/src/config/validation/step.ts +106 -2
  41. package/src/config/validation/workflow.ts +76 -0
  42. package/src/engine/create-run.ts +4 -0
  43. package/src/engine/gate-transitions.ts +58 -6
  44. package/src/engine/reconciliation-history.ts +61 -24
  45. package/src/engine/run-advance.ts +37 -4
  46. package/src/engine/run-lifecycle.ts +11 -0
  47. package/src/engine/run-reconciliation.ts +51 -21
  48. package/src/engine/run-validation.ts +236 -0
  49. package/src/engine/run-workflow-validation.ts +191 -0
  50. package/src/engine/state-types.ts +109 -4
  51. package/src/engine/state.ts +15 -1
  52. package/src/engine/step-trace.ts +495 -0
  53. package/src/engine/transitions.ts +1 -0
  54. package/src/harness/action-context.ts +31 -6
  55. package/src/harness/core-actions.ts +30 -1
  56. package/src/harness/delegation-control-actions.ts +27 -2
  57. package/src/harness/delegation-failure.ts +42 -9
  58. package/src/harness/delegation-plan.ts +96 -40
  59. package/src/harness/delegation-recovery-validation.ts +3 -1
  60. package/src/harness/delegation-response-actions.ts +73 -3
  61. package/src/harness/delegation-retry-policy.ts +5 -3
  62. package/src/harness/dependencies.ts +11 -2
  63. package/src/harness/gate-submission-action.ts +5 -31
  64. package/src/harness/lifecycle-actions.ts +4 -5
  65. package/src/harness/pause-actions.ts +10 -1
  66. package/src/harness/plannotator-result-actions.ts +20 -11
  67. package/src/harness/prompt-gate-actions.ts +40 -2
  68. package/src/harness/resume-action.ts +75 -1
  69. package/src/harness/start-actions.ts +77 -3
  70. package/src/harness/status-actions.ts +7 -17
  71. package/src/harness/step-effects.ts +61 -0
  72. package/src/harness/step-execution-actions.ts +181 -25
  73. package/src/harness/step-reporting.ts +168 -0
  74. package/src/harness/types.ts +1 -0
  75. package/src/harness/workspace-directory.ts +67 -0
  76. package/src/harness.ts +37 -9
  77. package/src/integrations/plannotator-requests.ts +40 -5
  78. package/src/integrations/subagents/child-policy-sections.ts +71 -72
  79. package/src/integrations/subagents/child-policy-types.ts +7 -7
  80. package/src/integrations/subagents/child-policy-validation.ts +10 -4
  81. package/src/integrations/subagents/child-runtime-completion.ts +1 -0
  82. package/src/integrations/subagents/child-runtime-files.ts +29 -86
  83. package/src/integrations/subagents/child-runtime-policy.ts +8 -33
  84. package/src/integrations/subagents/child-runtime.ts +3 -15
  85. package/src/integrations/subagents/delegated-result.ts +1 -0
  86. package/src/integrations/subagents/diagnostic-format.ts +5 -0
  87. package/src/integrations/subagents/diagnostic-types.ts +7 -1
  88. package/src/integrations/subagents/diagnostics.ts +2 -0
  89. package/src/integrations/subagents/failure-correlation.ts +52 -2
  90. package/src/integrations/subagents/failure-transcript.ts +60 -1
  91. package/src/integrations/subagents/replay-audit.ts +0 -1
  92. package/src/integrations/subagents/replay-safety.ts +12 -27
  93. package/src/integrations/subagents/session-diagnostics.ts +102 -3
  94. package/src/policy/bash-authorization.ts +2 -21
  95. package/src/policy/bash-types.ts +0 -5
  96. package/src/policy/bash.ts +1 -2
  97. package/src/policy/tool-call-authorization.ts +1 -7
  98. package/src/preflight.ts +0 -5
  99. package/src/prompt/retry-task.ts +11 -3
  100. package/src/prompt/step-contract.ts +10 -56
  101. package/src/prompt/step-sections.ts +8 -10
  102. package/src/prompt/step-task.ts +41 -10
  103. package/src/prompt/template.ts +5 -2
  104. package/src/runtime/completion-tool.ts +14 -0
  105. package/src/runtime/main-step-completion.ts +3 -0
  106. package/src/runtime/main-step-policy.ts +2 -1
  107. package/src/runtime/main-step-runtime-types.ts +9 -1
  108. package/src/runtime/main-step-runtime.ts +2 -0
  109. package/src/runtime/main-step-state.ts +6 -0
  110. package/src/runtime/main-step-trace.ts +45 -0
  111. package/src/runtime/step-result.ts +57 -0
  112. package/src/step-log.ts +178 -0
  113. package/src/workflow-doctor.ts +254 -0
  114. package/src/workflow-status/format-status.ts +32 -3
  115. package/src/workflow-status/render-board.ts +7 -4
  116. package/src/workflow-status/render-path.ts +51 -24
  117. package/src/workflow-status/render-step-detail.ts +379 -0
  118. package/src/workflow-status/render-summary.ts +22 -0
  119. package/src/workflow-status/transcript-reader.ts +231 -0
  120. package/src/workflow-status/types.ts +8 -0
  121. package/src/workflow-status/view.ts +161 -14
  122. package/src/workflow-status.ts +6 -0
  123. package/src/policy/approved-command-extraction.ts +0 -71
  124. package/src/policy/approved-commands.ts +0 -15
  125. package/src/policy/bash-read-only.ts +0 -161
  126. package/src/policy/restricted-git.ts +0 -30
  127. package/src/policy/reviewed-artifact.ts +0 -97
  128. package/src/policy/reviewed-command-safety.ts +0 -150
  129. package/src/policy/reviewed-command-shape.ts +0 -49
  130. package/src/policy/reviewed-repository-contract.ts +0 -138
  131. package/src/policy/reviewed-repository-cwd.ts +0 -102
@@ -15,8 +15,17 @@ import {
15
15
  } from './formatting.ts';
16
16
  import { padAnsi } from './layout.ts';
17
17
  import { renderBoard, renderEmptyBoard } from './render-board.ts';
18
+ import { buildPathEntries } from './render-path.ts';
19
+ import {
20
+ renderStepDetail,
21
+ selectedStepDetail,
22
+ stepTranscriptCacheKey,
23
+ type StepTranscriptViewState,
24
+ } from './render-step-detail.ts';
25
+ import { readStepTranscript } from './transcript-reader.ts';
18
26
  import type {
19
27
  SnapshotProvider,
28
+ StepTranscriptLoader,
20
29
  StatusViewTui,
21
30
  WorkflowStatusTheme,
22
31
  } from './types.ts';
@@ -31,10 +40,13 @@ export type WorkflowStatusViewDependencies = {
31
40
  intervalMilliseconds: number,
32
41
  ) => RefreshTimer;
33
42
  readonly cancelRefresh: (timer: RefreshTimer) => void;
43
+ readonly loadStepTranscript: StepTranscriptLoader;
34
44
  };
35
45
 
36
46
  type ViewportState = {
37
47
  readonly isClosed: boolean;
48
+ readonly mode: 'board' | 'detail';
49
+ readonly selectedIndex: number;
38
50
  readonly scrollOffset: number;
39
51
  readonly viewportRows: number;
40
52
  readonly contentRows: number;
@@ -51,11 +63,20 @@ const DEFAULT_VIEW_DEPENDENCIES = {
51
63
  cancelRefresh: (timer) => {
52
64
  clearInterval(timer);
53
65
  },
66
+ loadStepTranscript: async (attempt) =>
67
+ attempt.transcript
68
+ ? readStepTranscript(attempt.transcript)
69
+ : {
70
+ status: 'unavailable',
71
+ reason: 'No trusted child transcript reference was recorded.',
72
+ },
54
73
  } as const satisfies WorkflowStatusViewDependencies;
55
74
 
56
75
  function initialViewportState(): ViewportState {
57
76
  return {
58
77
  isClosed: false,
78
+ mode: 'board',
79
+ selectedIndex: -1,
59
80
  scrollOffset: 0,
60
81
  viewportRows: 0,
61
82
  contentRows: 0,
@@ -69,6 +90,7 @@ function paginateBoard(
69
90
  terminalRows: number | undefined,
70
91
  statusShortcutLabel: string,
71
92
  theme: WorkflowStatusTheme,
93
+ controls: string,
72
94
  ): PaginatedBoard {
73
95
  const maximumRows =
74
96
  terminalRows === undefined
@@ -82,8 +104,8 @@ function paginateBoard(
82
104
  const last = Math.min(lines.length, scrollOffset + contentHeight);
83
105
  const hint =
84
106
  maximumOffset > 0
85
- ? `↑/↓ PgUp/PgDn Home/End · rows ${first}-${last}/${lines.length} · ${statusShortcutLabel} / q / Esc hide`
86
- : `${statusShortcutLabel} / q / Esc hide · live refresh`;
107
+ ? `${controls} · rows ${first}-${last}/${lines.length} · ${statusShortcutLabel} / q hide`
108
+ : `${controls} · ${statusShortcutLabel} / q hide · live refresh`;
87
109
  return {
88
110
  state: {
89
111
  ...state,
@@ -114,6 +136,8 @@ export class WorkflowStatusView implements Component {
114
136
  private timer: RefreshTimer | undefined;
115
137
  private state = initialViewportState();
116
138
  private readonly statusShortcutLabel: string;
139
+ private readonly dependencies: WorkflowStatusViewDependencies;
140
+ private readonly transcriptCache = new Map<string, StepTranscriptViewState>();
117
141
 
118
142
  /**
119
143
  * Creates the stateful adapter around the pure status renderer.
@@ -131,9 +155,10 @@ export class WorkflowStatusView implements Component {
131
155
  private readonly theme: WorkflowStatusTheme,
132
156
  private readonly done: () => void,
133
157
  private readonly statusShortcut: KeyId = DEFAULT_STATUS_SHORTCUT,
134
- private readonly dependencies: WorkflowStatusViewDependencies = DEFAULT_VIEW_DEPENDENCIES,
158
+ dependencies: Partial<WorkflowStatusViewDependencies> = {},
135
159
  ) {
136
160
  this.statusShortcutLabel = formatShortcutLabel(statusShortcut);
161
+ this.dependencies = { ...DEFAULT_VIEW_DEPENDENCIES, ...dependencies };
137
162
  }
138
163
 
139
164
  /** Start periodic live-refresh requests. */
@@ -158,7 +183,6 @@ export class WorkflowStatusView implements Component {
158
183
  if (
159
184
  data === 'q' ||
160
185
  data === 'Q' ||
161
- matchesKey(data, 'escape') ||
162
186
  matchesKey(data, 'ctrl+c') ||
163
187
  matchesKey(data, 'ctrl+d') ||
164
188
  matchesKey(data, this.statusShortcut)
@@ -166,11 +190,43 @@ export class WorkflowStatusView implements Component {
166
190
  this.close();
167
191
  return;
168
192
  }
193
+ if (matchesKey(data, 'escape')) {
194
+ if (this.state.mode === 'detail') {
195
+ this.showBoard();
196
+ } else {
197
+ this.close();
198
+ }
199
+ return;
200
+ }
169
201
  const pageSize = Math.max(1, this.state.viewportRows - 2);
202
+ if (this.state.mode === 'detail') {
203
+ if (matchesKey(data, Key.left) || data === 'h') {
204
+ this.showBoard();
205
+ } else if (matchesKey(data, Key.down) || data === 'j') {
206
+ this.setScrollOffset(this.state.scrollOffset + 1);
207
+ } else if (matchesKey(data, Key.up) || data === 'k') {
208
+ this.setScrollOffset(this.state.scrollOffset - 1);
209
+ } else if (matchesKey(data, Key.pageDown)) {
210
+ this.setScrollOffset(this.state.scrollOffset + pageSize);
211
+ } else if (matchesKey(data, Key.pageUp)) {
212
+ this.setScrollOffset(this.state.scrollOffset - pageSize);
213
+ } else if (matchesKey(data, Key.home)) {
214
+ this.setScrollOffset(0);
215
+ } else if (matchesKey(data, Key.end)) {
216
+ this.setScrollOffset(Number.MAX_SAFE_INTEGER);
217
+ }
218
+ return;
219
+ }
170
220
  if (matchesKey(data, Key.down) || data === 'j') {
171
- this.setScrollOffset(this.state.scrollOffset + 1);
221
+ this.moveSelection(1);
172
222
  } else if (matchesKey(data, Key.up) || data === 'k') {
173
- this.setScrollOffset(this.state.scrollOffset - 1);
223
+ this.moveSelection(-1);
224
+ } else if (
225
+ matchesKey(data, Key.enter) ||
226
+ matchesKey(data, Key.right) ||
227
+ data === 'l'
228
+ ) {
229
+ this.openDetail();
174
230
  } else if (matchesKey(data, Key.pageDown)) {
175
231
  this.setScrollOffset(this.state.scrollOffset + pageSize);
176
232
  } else if (matchesKey(data, Key.pageUp)) {
@@ -198,15 +254,28 @@ export class WorkflowStatusView implements Component {
198
254
  }
199
255
 
200
256
  const contentWidth = viewportWidth - 2;
257
+ if (snapshot) this.normalizeSelection(snapshot);
201
258
  const lines = snapshot
202
- ? renderBoard(
203
- this.theme,
204
- snapshot,
205
- contentWidth,
206
- false,
207
- this.statusShortcutLabel,
208
- )
259
+ ? this.state.mode === 'detail'
260
+ ? renderStepDetail(
261
+ this.theme,
262
+ snapshot,
263
+ this.state.selectedIndex,
264
+ this.transcriptCache,
265
+ contentWidth,
266
+ )
267
+ : renderBoard(
268
+ this.theme,
269
+ snapshot,
270
+ contentWidth,
271
+ false,
272
+ this.statusShortcutLabel,
273
+ this.state.selectedIndex,
274
+ )
209
275
  : renderEmptyBoard(this.theme, contentWidth);
276
+ if (snapshot && this.state.mode === 'detail') {
277
+ this.ensureSelectedTranscripts(snapshot);
278
+ }
210
279
  const rendered = lines.map((line) =>
211
280
  padAnsi(truncateToWidth(line, contentWidth, '…'), viewportWidth),
212
281
  );
@@ -217,6 +286,9 @@ export class WorkflowStatusView implements Component {
217
286
  this.tui.terminal?.rows,
218
287
  this.statusShortcutLabel,
219
288
  this.theme,
289
+ this.state.mode === 'detail'
290
+ ? '↑/↓ or j/k scroll · PgUp/PgDn · ←/h/Esc back'
291
+ : '↑/↓ or j/k select · Enter/→/l inspect · PgUp/PgDn',
220
292
  );
221
293
  this.state = page.state;
222
294
  return page.lines;
@@ -229,6 +301,81 @@ export class WorkflowStatusView implements Component {
229
301
  this.tui.requestRender(true);
230
302
  }
231
303
 
304
+ private normalizeSelection(
305
+ snapshot: NonNullable<ReturnType<SnapshotProvider>>,
306
+ ): void {
307
+ const entries = buildPathEntries(snapshot);
308
+ const selectedIndex =
309
+ entries.length === 0
310
+ ? -1
311
+ : this.state.selectedIndex < 0
312
+ ? entries.length - 1
313
+ : Math.min(this.state.selectedIndex, entries.length - 1);
314
+ if (selectedIndex !== this.state.selectedIndex) {
315
+ this.state = { ...this.state, selectedIndex };
316
+ }
317
+ if (entries.length === 0 && this.state.mode === 'detail') {
318
+ this.state = { ...this.state, mode: 'board', scrollOffset: 0 };
319
+ }
320
+ }
321
+
322
+ private moveSelection(delta: number): void {
323
+ const snapshot = this.getSnapshot();
324
+ if (!snapshot) return;
325
+ this.normalizeSelection(snapshot);
326
+ const entries = buildPathEntries(snapshot);
327
+ if (entries.length === 0) return;
328
+ const selectedIndex = Math.max(
329
+ 0,
330
+ Math.min(this.state.selectedIndex + delta, entries.length - 1),
331
+ );
332
+ if (selectedIndex === this.state.selectedIndex) return;
333
+ this.state = { ...this.state, selectedIndex };
334
+ this.tui.requestRender(true);
335
+ }
336
+
337
+ private openDetail(): void {
338
+ const snapshot = this.getSnapshot();
339
+ if (!snapshot) return;
340
+ this.normalizeSelection(snapshot);
341
+ if (!selectedStepDetail(snapshot, this.state.selectedIndex)) return;
342
+ this.state = { ...this.state, mode: 'detail', scrollOffset: 0 };
343
+ this.ensureSelectedTranscripts(snapshot);
344
+ this.tui.requestRender(true);
345
+ }
346
+
347
+ private showBoard(): void {
348
+ if (this.state.mode === 'board') return;
349
+ this.state = { ...this.state, mode: 'board', scrollOffset: 0 };
350
+ this.tui.requestRender(true);
351
+ }
352
+
353
+ private ensureSelectedTranscripts(
354
+ snapshot: NonNullable<ReturnType<SnapshotProvider>>,
355
+ ): void {
356
+ const detail = selectedStepDetail(snapshot, this.state.selectedIndex);
357
+ if (!detail) return;
358
+ for (const attempt of detail.attempts) {
359
+ if (attempt.kind !== 'subagent' || !attempt.transcript) continue;
360
+ const key = stepTranscriptCacheKey(snapshot.run.runId, attempt);
361
+ if (this.transcriptCache.has(key)) continue;
362
+ this.transcriptCache.set(key, { status: 'loading' });
363
+ void this.dependencies.loadStepTranscript(attempt).then(
364
+ (result) => {
365
+ this.transcriptCache.set(key, result);
366
+ if (!this.state.isClosed) this.tui.requestRender(true);
367
+ },
368
+ () => {
369
+ this.transcriptCache.set(key, {
370
+ status: 'unavailable',
371
+ reason: 'The child transcript could not be loaded safely.',
372
+ });
373
+ if (!this.state.isClosed) this.tui.requestRender(true);
374
+ },
375
+ );
376
+ }
377
+ }
378
+
232
379
  private close(): void {
233
380
  if (this.state.isClosed) return;
234
381
  this.state = { ...this.state, isClosed: true };
@@ -243,7 +390,7 @@ export async function showWorkflowStatus(
243
390
  ctx: ExtensionContext,
244
391
  getSnapshot: SnapshotProvider,
245
392
  statusShortcut: KeyId = DEFAULT_STATUS_SHORTCUT,
246
- dependencies: WorkflowStatusViewDependencies = DEFAULT_VIEW_DEPENDENCIES,
393
+ dependencies: Partial<WorkflowStatusViewDependencies> = {},
247
394
  ): Promise<void> {
248
395
  await ctx.ui.custom<undefined>(
249
396
  (tui, theme, _keybindings, done) => {
@@ -1,4 +1,5 @@
1
1
  export {
2
+ formatWorkflowProgressStatus,
2
3
  formatWorkflowStatusBoard,
3
4
  formatWorkflowStatusText,
4
5
  } from './workflow-status/format-status.ts';
@@ -14,7 +15,12 @@ export {
14
15
  export type {
15
16
  SnapshotProvider,
16
17
  StatusViewTui,
18
+ StepTranscriptLoader,
17
19
  WorkflowStatusExecution,
18
20
  WorkflowStatusSnapshot,
19
21
  WorkflowStatusTheme,
20
22
  } from './workflow-status/types.ts';
23
+ export {
24
+ readStepTranscript,
25
+ type StepTranscriptLog,
26
+ } from './workflow-status/transcript-reader.ts';
@@ -1,71 +0,0 @@
1
- import type { BashApprovalSource } from '../config/types.ts';
2
- import {
3
- parseJsonDocuments,
4
- remoteActionCommands,
5
- verificationCommands,
6
- } from './reviewed-artifact.ts';
7
- import {
8
- isSafeRemoteActionCommand,
9
- isSafeVerificationCommand,
10
- } from './reviewed-command-safety.ts';
11
-
12
- const commandsFromSource = (
13
- document: unknown,
14
- source: BashApprovalSource,
15
- ): Array<string> => {
16
- if (source === 'verification-worker') {
17
- return verificationCommands(document, 'worker').filter(
18
- isSafeVerificationCommand,
19
- );
20
- }
21
- if (source === 'verification-reviewer') {
22
- return verificationCommands(document, 'reviewer').filter(
23
- isSafeVerificationCommand,
24
- );
25
- }
26
- return remoteActionCommands(document).filter(isSafeRemoteActionCommand);
27
- };
28
-
29
- /**
30
- * Extracts exact Bash capabilities from human-approved, machine-readable JSON.
31
- *
32
- * @param artifact - Artifact displayed by the review gate.
33
- * @param sources - Approved artifact sections to inspect.
34
- * @returns Unique safe commands in source order.
35
- */
36
- export const extractApprovedBashCommands = (
37
- artifact: string,
38
- sources: ReadonlyArray<BashApprovalSource>,
39
- ): Array<string> => {
40
- if (!artifact.trim() || sources.length === 0) return [];
41
-
42
- const commands = parseJsonDocuments(artifact).flatMap((document) =>
43
- sources.flatMap((source) => commandsFromSource(document, source)),
44
- );
45
- return [...new Set(commands)];
46
- };
47
-
48
- /**
49
- * Narrows reviewed commands to those retained by the latest step handoff.
50
- *
51
- * A child can remove reviewed authority, but its unreviewed output can never
52
- * introduce a new Bash capability.
53
- *
54
- * @param artifact - Original human-approved artifact.
55
- * @param handoff - Latest completed-step handoff.
56
- * @param sources - Approved artifact sections to inspect.
57
- * @returns Commands present in both artifacts.
58
- */
59
- export const narrowApprovedBashCommands = (
60
- artifact: string,
61
- handoff: string,
62
- sources: ReadonlyArray<BashApprovalSource>,
63
- ): Array<string> => {
64
- const approvedCommands = extractApprovedBashCommands(artifact, sources);
65
- if (approvedCommands.length === 0) return [];
66
-
67
- const retainedCommands = new Set(
68
- extractApprovedBashCommands(handoff, sources),
69
- );
70
- return approvedCommands.filter((command) => retainedCommands.has(command));
71
- };
@@ -1,15 +0,0 @@
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';
@@ -1,161 +0,0 @@
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
- };
@@ -1,30 +0,0 @@
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
- };
@@ -1,97 +0,0 @@
1
- export type ParsedJsonDocuments = {
2
- readonly documents: ReadonlyArray<unknown>;
3
- readonly hasMalformedCandidate: boolean;
4
- };
5
-
6
- /**
7
- * Narrows an unknown value to a non-array record.
8
- *
9
- * @param value - Value to inspect.
10
- * @returns `true` when the value is a record.
11
- */
12
- export const isRecord = (
13
- value: unknown,
14
- ): value is Readonly<Record<string, unknown>> =>
15
- value !== null && typeof value === 'object' && !Array.isArray(value);
16
-
17
- /**
18
- * Parses a complete JSON artifact and JSON fenced blocks while retaining
19
- * whether any JSON-looking candidate was malformed.
20
- *
21
- * @param text - Reviewed artifact text.
22
- * @returns Parsed documents and malformed-candidate state.
23
- */
24
- export const parseJsonDocumentsWithValidity = (
25
- text: string,
26
- ): ParsedJsonDocuments => {
27
- const documents: Array<unknown> = [];
28
- let hasMalformedCandidate = false;
29
- const addDocument = (candidate: string): void => {
30
- try {
31
- documents.push(JSON.parse(candidate));
32
- } catch {
33
- hasMalformedCandidate = true;
34
- }
35
- };
36
-
37
- const trimmed = text.trim();
38
- if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
39
- addDocument(trimmed);
40
- }
41
-
42
- const fences = /```(?:json)?[ \t]*\r?\n([\s\S]*?)```/giu;
43
- for (const match of text.matchAll(fences)) {
44
- const candidate = match[1]?.trim();
45
- if (candidate) addDocument(candidate);
46
- }
47
- return { documents, hasMalformedCandidate };
48
- };
49
-
50
- /**
51
- * Parses all valid JSON documents from a reviewed artifact.
52
- *
53
- * @param text - Reviewed artifact text.
54
- * @returns Valid JSON documents in source order.
55
- */
56
- export const parseJsonDocuments = (text: string): ReadonlyArray<unknown> =>
57
- parseJsonDocumentsWithValidity(text).documents;
58
-
59
- /**
60
- * Extracts verification commands for one role from a parsed artifact.
61
- *
62
- * @param value - Parsed artifact document.
63
- * @param role - Verification role whose commands should be returned.
64
- * @returns Command strings in artifact order.
65
- */
66
- export const verificationCommands = (
67
- value: unknown,
68
- role: 'worker' | 'reviewer',
69
- ): Array<string> => {
70
- if (!isRecord(value) || !Array.isArray(value.repositories)) return [];
71
- return value.repositories.flatMap((repository) => {
72
- if (!isRecord(repository) || !Array.isArray(repository[role])) return [];
73
- return repository[role].flatMap((check) =>
74
- isRecord(check) && typeof check.command === 'string'
75
- ? [check.command]
76
- : [],
77
- );
78
- });
79
- };
80
-
81
- /**
82
- * Extracts Bash command actions from a parsed remote-action artifact.
83
- *
84
- * @param value - Parsed artifact document.
85
- * @returns Bash commands in artifact order.
86
- */
87
- export const remoteActionCommands = (value: unknown): Array<string> => {
88
- if (!isRecord(value) || !Array.isArray(value.actions)) return [];
89
- return value.actions.flatMap((action) =>
90
- isRecord(action) &&
91
- action.toolName === 'bash' &&
92
- isRecord(action.input) &&
93
- typeof action.input.command === 'string'
94
- ? [action.input.command]
95
- : [],
96
- );
97
- };