@wichayutdew/pi-workflows 1.0.1 → 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.
- package/README.md +312 -223
- package/agents/step.md +4 -11
- package/dist/index.js +2554 -976
- package/examples/mr-comments.workflow.yaml +16 -8
- package/examples/prompts/mr-comments/implement.md +13 -9
- package/examples/prompts/mr-comments/plan.md +44 -15
- package/examples/prompts/mr-comments/verify.md +8 -7
- package/examples/settings.yaml +7 -1
- package/examples/starter-kit/mr-comment.workflow.yaml +149 -0
- package/examples/starter-kit/mr-review.workflow.yaml +130 -0
- package/examples/starter-kit/steps/mr-comment/fetch.md +33 -0
- package/examples/starter-kit/steps/mr-comment/implement.md +38 -0
- package/examples/starter-kit/steps/mr-comment/plan.md +62 -0
- package/examples/starter-kit/steps/mr-comment/publish.md +36 -0
- package/examples/starter-kit/steps/mr-comment/verify.md +35 -0
- package/examples/starter-kit/steps/mr-review/fetch.md +25 -0
- package/examples/starter-kit/steps/mr-review/publish.md +30 -0
- package/examples/starter-kit/steps/mr-review/review.md +69 -0
- package/examples/starter-kit/steps/mr-review/verify.md +24 -0
- package/examples/starter-kit/steps/shared/prepare-workspace.md +57 -0
- package/examples/starter-kit/steps/ticket/implement.md +35 -0
- package/examples/starter-kit/steps/ticket/plan.md +40 -0
- package/examples/starter-kit/steps/ticket/verify.md +29 -0
- package/examples/starter-kit/steps/work/implement.md +34 -0
- package/examples/starter-kit/steps/work/plan.md +38 -0
- package/examples/starter-kit/steps/work/verify.md +30 -0
- package/examples/starter-kit/ticket.workflow.yaml +100 -0
- package/examples/starter-kit/work.workflow.yaml +97 -0
- package/package.json +3 -3
- package/schemas/workflow.schema.json +78 -44
- package/src/command-names.ts +1 -0
- package/src/commands.ts +33 -4
- package/src/config/ceiling.ts +6 -12
- package/src/config/load-workflows.ts +12 -1
- package/src/config/step-digests.ts +19 -0
- package/src/config/types.ts +17 -22
- package/src/config/validation/permissions.ts +5 -66
- package/src/config/validation/prompt.ts +1 -0
- package/src/config/validation/shared.ts +0 -2
- package/src/config/validation/step.ts +106 -2
- package/src/config/validation/workflow.ts +76 -0
- package/src/engine/create-run.ts +3 -0
- package/src/engine/gate-transitions.ts +58 -9
- package/src/engine/reconciliation-history.ts +61 -24
- package/src/engine/run-advance.ts +37 -4
- package/src/engine/run-lifecycle.ts +11 -0
- package/src/engine/run-reconciliation.ts +51 -21
- package/src/engine/run-validation.ts +235 -0
- package/src/engine/run-workflow-validation.ts +191 -0
- package/src/engine/state-types.ts +108 -5
- package/src/engine/state.ts +15 -1
- package/src/engine/step-trace.ts +495 -0
- package/src/engine/transitions.ts +1 -0
- package/src/harness/action-context.ts +31 -6
- package/src/harness/core-actions.ts +30 -1
- package/src/harness/delegation-control-actions.ts +27 -2
- package/src/harness/delegation-failure.ts +42 -9
- package/src/harness/delegation-plan.ts +96 -40
- package/src/harness/delegation-recovery-validation.ts +3 -1
- package/src/harness/delegation-response-actions.ts +73 -3
- package/src/harness/delegation-retry-policy.ts +5 -3
- package/src/harness/dependencies.ts +11 -2
- package/src/harness/gate-submission-action.ts +5 -31
- package/src/harness/lifecycle-actions.ts +4 -5
- package/src/harness/pause-actions.ts +10 -1
- package/src/harness/plannotator-result-actions.ts +20 -11
- package/src/harness/prompt-gate-actions.ts +40 -2
- package/src/harness/resume-action.ts +75 -1
- package/src/harness/start-actions.ts +77 -3
- package/src/harness/status-actions.ts +7 -17
- package/src/harness/step-effects.ts +61 -0
- package/src/harness/step-execution-actions.ts +181 -25
- package/src/harness/step-reporting.ts +168 -0
- package/src/harness/types.ts +1 -0
- package/src/harness/workspace-directory.ts +67 -0
- package/src/harness.ts +37 -9
- package/src/integrations/plannotator-requests.ts +40 -5
- package/src/integrations/subagents/child-policy-sections.ts +71 -84
- package/src/integrations/subagents/child-policy-types.ts +7 -7
- package/src/integrations/subagents/child-policy-validation.ts +10 -4
- package/src/integrations/subagents/child-runtime-completion.ts +1 -0
- package/src/integrations/subagents/child-runtime-files.ts +29 -86
- package/src/integrations/subagents/child-runtime-policy.ts +8 -33
- package/src/integrations/subagents/child-runtime.ts +3 -15
- package/src/integrations/subagents/delegated-result.ts +1 -0
- package/src/integrations/subagents/diagnostic-format.ts +5 -0
- package/src/integrations/subagents/diagnostic-types.ts +7 -1
- package/src/integrations/subagents/diagnostics.ts +2 -0
- package/src/integrations/subagents/failure-correlation.ts +52 -2
- package/src/integrations/subagents/failure-transcript.ts +60 -1
- package/src/integrations/subagents/replay-audit.ts +0 -1
- package/src/integrations/subagents/replay-safety.ts +12 -27
- package/src/integrations/subagents/session-diagnostics.ts +102 -3
- package/src/policy/bash-authorization.ts +2 -21
- package/src/policy/bash-types.ts +0 -5
- package/src/policy/bash.ts +1 -2
- package/src/policy/tool-call-authorization.ts +1 -7
- package/src/preflight.ts +0 -5
- package/src/prompt/retry-task.ts +11 -3
- package/src/prompt/step-contract.ts +10 -56
- package/src/prompt/step-sections.ts +8 -10
- package/src/prompt/step-task.ts +41 -10
- package/src/prompt/template.ts +3 -2
- package/src/runtime/completion-tool.ts +14 -0
- package/src/runtime/main-step-completion.ts +3 -0
- package/src/runtime/main-step-policy.ts +2 -1
- package/src/runtime/main-step-runtime-types.ts +9 -1
- package/src/runtime/main-step-runtime.ts +2 -0
- package/src/runtime/main-step-state.ts +6 -0
- package/src/runtime/main-step-trace.ts +45 -0
- package/src/runtime/step-result.ts +57 -0
- package/src/step-log.ts +178 -0
- package/src/workflow-doctor.ts +254 -0
- package/src/workflow-status/format-status.ts +32 -3
- package/src/workflow-status/render-board.ts +7 -4
- package/src/workflow-status/render-path.ts +51 -24
- package/src/workflow-status/render-step-detail.ts +379 -0
- package/src/workflow-status/render-summary.ts +22 -0
- package/src/workflow-status/transcript-reader.ts +231 -0
- package/src/workflow-status/types.ts +8 -0
- package/src/workflow-status/view.ts +161 -14
- package/src/workflow-status.ts +6 -0
- package/src/policy/approved-command-extraction.ts +0 -71
- package/src/policy/approved-commands.ts +0 -15
- package/src/policy/bash-read-only.ts +0 -161
- package/src/policy/restricted-git.ts +0 -30
- package/src/policy/reviewed-artifact.ts +0 -97
- package/src/policy/reviewed-command-safety.ts +0 -150
- package/src/policy/reviewed-command-shape.ts +0 -49
- package/src/policy/reviewed-repository-contract.ts +0 -138
- package/src/policy/reviewed-repository-cwd.ts +0 -102
package/src/harness.ts
CHANGED
|
@@ -53,6 +53,7 @@ import { createGateSubmissionAction } from './harness/gate-submission-action.ts'
|
|
|
53
53
|
import { createPromptGateActions } from './harness/prompt-gate-actions.ts';
|
|
54
54
|
import { createPlannotatorResultActions } from './harness/plannotator-result-actions.ts';
|
|
55
55
|
import { createCoreActions } from './harness/core-actions.ts';
|
|
56
|
+
import type { SettledStepReport } from './harness/step-reporting.ts';
|
|
56
57
|
import {
|
|
57
58
|
formatShortcutLabel,
|
|
58
59
|
type WorkflowStatusSnapshot,
|
|
@@ -106,14 +107,16 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
106
107
|
STATUS_ACTIONS.stopStatusRefresh;
|
|
107
108
|
private readonly registerWorkflowStatusShortcut: () => void =
|
|
108
109
|
STATUS_ACTIONS.registerWorkflowStatusShortcut;
|
|
109
|
-
private readonly openWorkflowStatus: (context: ExtensionContext) => void =
|
|
110
|
-
STATUS_ACTIONS.openWorkflowStatus;
|
|
111
110
|
private readonly showWorkflowStatus: (
|
|
112
111
|
context: ExtensionContext,
|
|
113
112
|
) => Promise<void> = STATUS_ACTIONS.showWorkflowStatus;
|
|
114
113
|
private readonly listWorkflows: (
|
|
115
114
|
context: ExtensionCommandContext,
|
|
116
115
|
) => Promise<void> = START_ACTIONS.listWorkflows;
|
|
116
|
+
private readonly doctorWorkflows: (
|
|
117
|
+
workflowId: string,
|
|
118
|
+
context: ExtensionCommandContext,
|
|
119
|
+
) => Promise<void> = START_ACTIONS.doctorWorkflows;
|
|
117
120
|
private readonly startNow: (
|
|
118
121
|
workflowId: string,
|
|
119
122
|
input: string,
|
|
@@ -133,6 +136,7 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
133
136
|
) => Promise<void> = PAUSE_ACTIONS.abortNow;
|
|
134
137
|
private readonly resumeNow: (
|
|
135
138
|
context: ExtensionCommandContext,
|
|
139
|
+
input?: string,
|
|
136
140
|
) => Promise<void> = RESUME_ACTION.resumeNow;
|
|
137
141
|
private readonly registerMultilineCommandInput: () => void =
|
|
138
142
|
LIFECYCLE_ACTIONS.registerMultilineCommandInput;
|
|
@@ -149,6 +153,16 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
149
153
|
run: WorkflowRun,
|
|
150
154
|
step: WorkflowStep,
|
|
151
155
|
) => void = STEP_EXECUTION_ACTIONS.launchMainStep;
|
|
156
|
+
private readonly queueMainStepLog: (
|
|
157
|
+
identity: MainStepIdentity,
|
|
158
|
+
lines: ReadonlyArray<string>,
|
|
159
|
+
context: ExtensionContext,
|
|
160
|
+
) => Promise<void> = STEP_EXECUTION_ACTIONS.queueMainStepLog;
|
|
161
|
+
private readonly recordMainStepLog: (
|
|
162
|
+
identity: MainStepIdentity,
|
|
163
|
+
lines: ReadonlyArray<string>,
|
|
164
|
+
context: ExtensionContext,
|
|
165
|
+
) => Promise<void> = STEP_EXECUTION_ACTIONS.recordMainStepLog;
|
|
152
166
|
private readonly queueMainStepResult: (
|
|
153
167
|
identity: MainStepIdentity,
|
|
154
168
|
result: WorkflowStepResult | undefined,
|
|
@@ -186,11 +200,14 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
186
200
|
failure: DelegationFailureDetails | undefined,
|
|
187
201
|
reason: string,
|
|
188
202
|
) => boolean = DELEGATION_CONTROL_ACTIONS.retryDelegationAfterFailure;
|
|
189
|
-
private readonly pauseForDelegationFailure: (
|
|
190
|
-
|
|
203
|
+
private readonly pauseForDelegationFailure: (
|
|
204
|
+
reason: string,
|
|
205
|
+
failureSummary?: string,
|
|
206
|
+
) => void = DELEGATION_CONTROL_ACTIONS.pauseForDelegationFailure;
|
|
191
207
|
private readonly pauseForExecutionFailure: (
|
|
192
208
|
label: string,
|
|
193
209
|
reason: string,
|
|
210
|
+
failureSummary?: string,
|
|
194
211
|
) => void = DELEGATION_CONTROL_ACTIONS.pauseForExecutionFailure;
|
|
195
212
|
private readonly retainUnconfirmedDelegation: (
|
|
196
213
|
active: ActiveDelegation,
|
|
@@ -203,6 +220,7 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
203
220
|
workflow: LoadedWorkflow,
|
|
204
221
|
originalRun: WorkflowRun,
|
|
205
222
|
outcome: string,
|
|
223
|
+
summary: string,
|
|
206
224
|
artifact: string,
|
|
207
225
|
) => Promise<void> = GATE_SUBMISSION_ACTION.submitGate;
|
|
208
226
|
private readonly launchPromptReview: (
|
|
@@ -233,8 +251,10 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
233
251
|
PLANNOTATOR_RESULT_ACTIONS.registerPlannotatorResults;
|
|
234
252
|
private readonly handlePlannotatorResult: (data: unknown) => Promise<void> =
|
|
235
253
|
PLANNOTATOR_RESULT_ACTIONS.handlePlannotatorResult;
|
|
236
|
-
private readonly settleAfterTransition: (
|
|
237
|
-
|
|
254
|
+
private readonly settleAfterTransition: (
|
|
255
|
+
workflow: LoadedWorkflow,
|
|
256
|
+
report: SettledStepReport,
|
|
257
|
+
) => void = CORE_ACTIONS.settleAfterTransition;
|
|
238
258
|
private readonly preflight: (
|
|
239
259
|
workflow: LoadedWorkflow,
|
|
240
260
|
stepId: string,
|
|
@@ -297,6 +317,14 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
297
317
|
await this.listWorkflows(context);
|
|
298
318
|
}
|
|
299
319
|
|
|
320
|
+
/** Diagnoses declarative completion paths and loop risks. */
|
|
321
|
+
async doctor(
|
|
322
|
+
workflowId: string,
|
|
323
|
+
context: ExtensionCommandContext,
|
|
324
|
+
): Promise<void> {
|
|
325
|
+
await this.doctorWorkflows(workflowId, context);
|
|
326
|
+
}
|
|
327
|
+
|
|
300
328
|
/** Starts a loaded workflow with the supplied user input. */
|
|
301
329
|
start(
|
|
302
330
|
workflowId: string,
|
|
@@ -322,9 +350,9 @@ export class WorkflowHarness implements WorkflowCommandController {
|
|
|
322
350
|
return this.enqueueMutation(context, () => this.pauseNow(reason, context));
|
|
323
351
|
}
|
|
324
352
|
|
|
325
|
-
/** Resumes the active paused workflow
|
|
326
|
-
resume(context: ExtensionCommandContext): Promise<void> {
|
|
327
|
-
return this.enqueueMutation(context, () => this.resumeNow(context));
|
|
353
|
+
/** Resumes the active paused workflow with optional user guidance. */
|
|
354
|
+
resume(input: string, context: ExtensionCommandContext): Promise<void> {
|
|
355
|
+
return this.enqueueMutation(context, () => this.resumeNow(context, input));
|
|
328
356
|
}
|
|
329
357
|
|
|
330
358
|
/** Aborts the active workflow and cancels any delegated execution. */
|
|
@@ -35,6 +35,7 @@ type RequestResponseOptions<TResponse> = {
|
|
|
35
35
|
readonly timeoutMs: number;
|
|
36
36
|
readonly timeoutResponse: TResponse;
|
|
37
37
|
readonly normalize: (response: unknown) => TResponse;
|
|
38
|
+
readonly ignoreResponse?: (response: unknown) => boolean;
|
|
38
39
|
readonly dependencies: PlannotatorDependencies;
|
|
39
40
|
};
|
|
40
41
|
|
|
@@ -44,12 +45,13 @@ const requestResponse = <TResponse>({
|
|
|
44
45
|
timeoutMs,
|
|
45
46
|
timeoutResponse,
|
|
46
47
|
normalize,
|
|
48
|
+
ignoreResponse,
|
|
47
49
|
dependencies,
|
|
48
50
|
}: RequestResponseOptions<TResponse>): Promise<TResponse> =>
|
|
49
51
|
new Promise((resolve) => {
|
|
50
52
|
let isSettled = false;
|
|
51
53
|
const finish = (response: unknown): void => {
|
|
52
|
-
if (isSettled) return;
|
|
54
|
+
if (isSettled || ignoreResponse?.(response)) return;
|
|
53
55
|
isSettled = true;
|
|
54
56
|
dependencies.cancelTimeout(timer);
|
|
55
57
|
resolve(normalize(response));
|
|
@@ -65,6 +67,39 @@ const requestResponse = <TResponse>({
|
|
|
65
67
|
});
|
|
66
68
|
});
|
|
67
69
|
|
|
70
|
+
const MISSING_PLAN_CONTENT_RESPONSE =
|
|
71
|
+
'Missing planContent for plan-review request.';
|
|
72
|
+
|
|
73
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
74
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
75
|
+
|
|
76
|
+
const isUnclaimedPlanReviewResponse = (response: unknown): boolean =>
|
|
77
|
+
isRecord(response) &&
|
|
78
|
+
response.status === 'error' &&
|
|
79
|
+
response.error === MISSING_PLAN_CONTENT_RESPONSE;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Pi keeps its shared event bus across extension reloads. Plannotator versions
|
|
83
|
+
* that do not dispose their request listener can therefore receive one request
|
|
84
|
+
* multiple times and open one browser pane per stale listener. A consumable
|
|
85
|
+
* plan value lets exactly one listener claim the side-effecting request.
|
|
86
|
+
*/
|
|
87
|
+
const singleConsumerPlanPayload = (
|
|
88
|
+
planContent: string,
|
|
89
|
+
origin: string,
|
|
90
|
+
): Readonly<Record<string, unknown>> => {
|
|
91
|
+
let claimed = false;
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
get planContent(): string | undefined {
|
|
95
|
+
if (claimed) return undefined;
|
|
96
|
+
claimed = true;
|
|
97
|
+
return planContent;
|
|
98
|
+
},
|
|
99
|
+
origin,
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
68
103
|
/**
|
|
69
104
|
* Requests a new Plannotator plan review through the injected event bus.
|
|
70
105
|
*
|
|
@@ -83,10 +118,7 @@ export const requestPlannotatorReview = (
|
|
|
83
118
|
request: {
|
|
84
119
|
requestId,
|
|
85
120
|
action: 'plan-review',
|
|
86
|
-
payload:
|
|
87
|
-
planContent: content,
|
|
88
|
-
origin,
|
|
89
|
-
},
|
|
121
|
+
payload: singleConsumerPlanPayload(content, origin),
|
|
90
122
|
},
|
|
91
123
|
timeoutMs,
|
|
92
124
|
timeoutResponse: {
|
|
@@ -94,6 +126,9 @@ export const requestPlannotatorReview = (
|
|
|
94
126
|
error: `Plannotator did not respond within ${timeoutMs}ms`,
|
|
95
127
|
},
|
|
96
128
|
normalize: normalizePlannotatorStartResponse,
|
|
129
|
+
...(content.trim()
|
|
130
|
+
? { ignoreResponse: isUnclaimedPlanReviewResponse }
|
|
131
|
+
: {}),
|
|
97
132
|
dependencies,
|
|
98
133
|
});
|
|
99
134
|
|
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
import { isAbsolute,
|
|
2
|
-
import
|
|
1
|
+
import { isAbsolute, win32 } from 'node:path';
|
|
2
|
+
import {
|
|
3
|
+
MAX_WORKSPACE_ALLOWED_ROOTS,
|
|
4
|
+
MAX_WORKSPACE_PATH_CHARS,
|
|
5
|
+
type StepPermissions,
|
|
6
|
+
} from '../../config/types.ts';
|
|
3
7
|
import type { ChildStepPolicy } from './child-policy-types.ts';
|
|
4
8
|
|
|
5
9
|
type PolicySections = Pick<
|
|
6
10
|
ChildStepPolicy,
|
|
7
11
|
| 'permissions'
|
|
8
|
-
| 'approvedBashCommands'
|
|
9
|
-
| 'repositoryCwd'
|
|
10
|
-
| 'bootstrapCwd'
|
|
11
12
|
| 'outcomes'
|
|
12
13
|
| 'pauseOutcomes'
|
|
13
14
|
| 'summaryMaxChars'
|
|
14
15
|
| 'gateSubmitOutcome'
|
|
16
|
+
| 'workspace'
|
|
15
17
|
>;
|
|
16
18
|
|
|
17
19
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
@@ -20,6 +22,11 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
|
20
22
|
const isStringArray = (value: unknown): value is Array<string> =>
|
|
21
23
|
Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
22
24
|
|
|
25
|
+
const hasOnlyKeys = (
|
|
26
|
+
value: Readonly<Record<string, unknown>>,
|
|
27
|
+
allowed: ReadonlySet<string>,
|
|
28
|
+
): boolean => Object.keys(value).every((key) => allowed.has(key));
|
|
29
|
+
|
|
23
30
|
const isStepPermissions = (value: unknown): value is StepPermissions => {
|
|
24
31
|
if (!isRecord(value) || !isRecord(value.bash)) return false;
|
|
25
32
|
|
|
@@ -27,7 +34,6 @@ const isStepPermissions = (value: unknown): value is StepPermissions => {
|
|
|
27
34
|
const bashRules = Array.isArray(bash.allow) ? bash.allow : undefined;
|
|
28
35
|
const isValidMode =
|
|
29
36
|
bash.mode === 'deny' ||
|
|
30
|
-
bash.mode === 'read-only' ||
|
|
31
37
|
bash.mode === 'allow-list' ||
|
|
32
38
|
bash.mode === 'unrestricted';
|
|
33
39
|
const hasValidRules =
|
|
@@ -35,96 +41,33 @@ const isStepPermissions = (value: unknown): value is StepPermissions => {
|
|
|
35
41
|
bashRules.every(
|
|
36
42
|
(rule) =>
|
|
37
43
|
isRecord(rule) &&
|
|
44
|
+
hasOnlyKeys(rule, new Set(['executable', 'argsPrefix'])) &&
|
|
38
45
|
typeof rule.executable === 'string' &&
|
|
39
46
|
isStringArray(rule.argsPrefix),
|
|
40
47
|
);
|
|
41
|
-
const hasValidApprovedSources =
|
|
42
|
-
bash.approvedSources === undefined ||
|
|
43
|
-
(isStringArray(bash.approvedSources) &&
|
|
44
|
-
new Set(bash.approvedSources).size === bash.approvedSources.length &&
|
|
45
|
-
bash.approvedSources.every(
|
|
46
|
-
(source) =>
|
|
47
|
-
source === 'verification-worker' ||
|
|
48
|
-
source === 'verification-reviewer' ||
|
|
49
|
-
source === 'remote-actions' ||
|
|
50
|
-
source === 'remote-push' ||
|
|
51
|
-
source === 'remote-drafts',
|
|
52
|
-
));
|
|
53
|
-
const hasValidHandoffSources =
|
|
54
|
-
bash.handoffSources === undefined ||
|
|
55
|
-
(isStringArray(bash.handoffSources) &&
|
|
56
|
-
new Set(bash.handoffSources).size === bash.handoffSources.length &&
|
|
57
|
-
bash.handoffSources.every(
|
|
58
|
-
(source) =>
|
|
59
|
-
source === 'verification-worker' ||
|
|
60
|
-
source === 'verification-reviewer',
|
|
61
|
-
));
|
|
62
|
-
const hasValidApprovalShape =
|
|
63
|
-
bash.mode === 'allow-list'
|
|
64
|
-
? (bashRules?.length ?? 0) > 0 ||
|
|
65
|
-
(Array.isArray(bash.approvedSources) && bash.approvedSources.length > 0)
|
|
66
|
-
: bash.approvedSources === undefined;
|
|
67
|
-
|
|
68
48
|
return (
|
|
49
|
+
hasOnlyKeys(
|
|
50
|
+
value,
|
|
51
|
+
new Set(['tools', 'mcp', 'extensions', 'skills', 'bash']),
|
|
52
|
+
) &&
|
|
53
|
+
hasOnlyKeys(bash, new Set(['mode', 'allow'])) &&
|
|
69
54
|
isStringArray(value.tools) &&
|
|
70
55
|
isStringArray(value.mcp) &&
|
|
71
56
|
isStringArray(value.extensions) &&
|
|
72
57
|
isStringArray(value.skills) &&
|
|
73
58
|
isValidMode &&
|
|
74
59
|
hasValidRules &&
|
|
75
|
-
|
|
76
|
-
hasValidHandoffSources &&
|
|
77
|
-
hasValidApprovalShape
|
|
60
|
+
(bash.mode !== 'allow-list' || bashRules.length > 0)
|
|
78
61
|
);
|
|
79
62
|
};
|
|
80
63
|
|
|
81
64
|
const parsePermissions = (
|
|
82
65
|
value: Readonly<Record<string, unknown>>,
|
|
83
|
-
): Pick<PolicySections, 'permissions'
|
|
66
|
+
): Pick<PolicySections, 'permissions'> => {
|
|
84
67
|
if (!isStepPermissions(value.permissions)) {
|
|
85
68
|
throw new Error('child policy permissions are invalid');
|
|
86
69
|
}
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
approvedBashCommands !== undefined &&
|
|
90
|
-
(!isStringArray(approvedBashCommands) ||
|
|
91
|
-
new Set(approvedBashCommands).size !== approvedBashCommands.length)
|
|
92
|
-
) {
|
|
93
|
-
throw new Error('child policy approved Bash commands are invalid');
|
|
94
|
-
}
|
|
95
|
-
return {
|
|
96
|
-
permissions: value.permissions,
|
|
97
|
-
...(approvedBashCommands === undefined ? {} : { approvedBashCommands }),
|
|
98
|
-
};
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
const parseRepositoryPaths = (
|
|
102
|
-
value: Readonly<Record<string, unknown>>,
|
|
103
|
-
): Pick<PolicySections, 'repositoryCwd' | 'bootstrapCwd'> => {
|
|
104
|
-
const repositoryCwd = value.repositoryCwd;
|
|
105
|
-
const bootstrapCwd = value.bootstrapCwd;
|
|
106
|
-
if (
|
|
107
|
-
repositoryCwd !== undefined &&
|
|
108
|
-
(typeof repositoryCwd !== 'string' ||
|
|
109
|
-
!isAbsolute(repositoryCwd) ||
|
|
110
|
-
repositoryCwd.includes('\0'))
|
|
111
|
-
) {
|
|
112
|
-
throw new Error('child policy repository cwd is invalid');
|
|
113
|
-
}
|
|
114
|
-
if (
|
|
115
|
-
bootstrapCwd !== undefined &&
|
|
116
|
-
(typeof bootstrapCwd !== 'string' ||
|
|
117
|
-
!isAbsolute(bootstrapCwd) ||
|
|
118
|
-
bootstrapCwd.includes('\0') ||
|
|
119
|
-
typeof repositoryCwd !== 'string' ||
|
|
120
|
-
resolve(bootstrapCwd) === resolve(repositoryCwd))
|
|
121
|
-
) {
|
|
122
|
-
throw new Error('child policy bootstrap cwd is invalid');
|
|
123
|
-
}
|
|
124
|
-
return {
|
|
125
|
-
...(repositoryCwd === undefined ? {} : { repositoryCwd }),
|
|
126
|
-
...(bootstrapCwd === undefined ? {} : { bootstrapCwd }),
|
|
127
|
-
};
|
|
70
|
+
return { permissions: value.permissions };
|
|
128
71
|
};
|
|
129
72
|
|
|
130
73
|
const parseOutcomes = (
|
|
@@ -174,13 +117,57 @@ const parseOutcomes = (
|
|
|
174
117
|
};
|
|
175
118
|
};
|
|
176
119
|
|
|
120
|
+
const parseWorkspace = (
|
|
121
|
+
value: Readonly<Record<string, unknown>>,
|
|
122
|
+
outcomes: ReadonlyArray<string>,
|
|
123
|
+
): Pick<PolicySections, 'workspace'> => {
|
|
124
|
+
if (value.workspace === undefined) return {};
|
|
125
|
+
if (
|
|
126
|
+
!isRecord(value.workspace) ||
|
|
127
|
+
!hasOnlyKeys(value.workspace, new Set(['bindOn', 'allowedRoots']))
|
|
128
|
+
) {
|
|
129
|
+
throw new Error('child policy workspace is invalid');
|
|
130
|
+
}
|
|
131
|
+
const bindOn = value.workspace.bindOn;
|
|
132
|
+
const allowedRoots = value.workspace.allowedRoots;
|
|
133
|
+
if (
|
|
134
|
+
!isStringArray(bindOn) ||
|
|
135
|
+
bindOn.length === 0 ||
|
|
136
|
+
new Set(bindOn).size !== bindOn.length ||
|
|
137
|
+
bindOn.some((outcome) => !outcomes.includes(outcome))
|
|
138
|
+
) {
|
|
139
|
+
throw new Error('child policy workspace bindOn outcomes are invalid');
|
|
140
|
+
}
|
|
141
|
+
if (
|
|
142
|
+
!isStringArray(allowedRoots) ||
|
|
143
|
+
allowedRoots.length === 0 ||
|
|
144
|
+
allowedRoots.length > MAX_WORKSPACE_ALLOWED_ROOTS ||
|
|
145
|
+
new Set(allowedRoots).size !== allowedRoots.length ||
|
|
146
|
+
allowedRoots.some(
|
|
147
|
+
(root) =>
|
|
148
|
+
!root.trim() ||
|
|
149
|
+
root !== root.trim() ||
|
|
150
|
+
root.length > MAX_WORKSPACE_PATH_CHARS ||
|
|
151
|
+
root.includes('\0') ||
|
|
152
|
+
isAbsolute(root) ||
|
|
153
|
+
win32.parse(root).root !== '',
|
|
154
|
+
)
|
|
155
|
+
) {
|
|
156
|
+
throw new Error('child policy workspace allowed roots are invalid');
|
|
157
|
+
}
|
|
158
|
+
return { workspace: { bindOn, allowedRoots } };
|
|
159
|
+
};
|
|
160
|
+
|
|
177
161
|
/**
|
|
178
|
-
* Parses permission
|
|
162
|
+
* Parses permission and outcome sections of a child policy.
|
|
179
163
|
*/
|
|
180
164
|
export const parseChildPolicySections = (
|
|
181
165
|
value: Readonly<Record<string, unknown>>,
|
|
182
|
-
): PolicySections =>
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
166
|
+
): PolicySections => {
|
|
167
|
+
const outcomeSections = parseOutcomes(value);
|
|
168
|
+
return {
|
|
169
|
+
...parsePermissions(value),
|
|
170
|
+
...outcomeSections,
|
|
171
|
+
...parseWorkspace(value, outcomeSections.outcomes),
|
|
172
|
+
};
|
|
173
|
+
};
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
StepPermissions,
|
|
3
|
+
StepWorkspaceBinding,
|
|
4
|
+
} from '../../config/types.ts';
|
|
2
5
|
|
|
3
6
|
export type ChildStepPolicy = {
|
|
4
7
|
readonly version: 1;
|
|
@@ -8,22 +11,19 @@ export type ChildStepPolicy = {
|
|
|
8
11
|
readonly runId: string;
|
|
9
12
|
readonly stepId: string;
|
|
10
13
|
readonly stepTitle: string;
|
|
14
|
+
/** Effective run directory: the captured start cwd or accepted binding. */
|
|
15
|
+
readonly cwd: string;
|
|
11
16
|
readonly policyDigest: string;
|
|
12
17
|
readonly capabilityPath: string;
|
|
13
18
|
readonly capabilityToken: string;
|
|
14
19
|
readonly resultPath: string;
|
|
15
20
|
readonly permissions: StepPermissions;
|
|
16
|
-
/** Exact Bash command strings extracted from a reviewed gate artifact. */
|
|
17
|
-
readonly approvedBashCommands?: ReadonlyArray<string>;
|
|
18
|
-
/** Reviewed repository root that file mutations must remain inside. */
|
|
19
|
-
readonly repositoryCwd?: string;
|
|
20
|
-
/** Existing reviewed source directory used only to bootstrap repositoryCwd. */
|
|
21
|
-
readonly bootstrapCwd?: string;
|
|
22
21
|
readonly outcomes: ReadonlyArray<string>;
|
|
23
22
|
/** Outcomes that pause instead of advancing to another workflow step. */
|
|
24
23
|
readonly pauseOutcomes: ReadonlyArray<string>;
|
|
25
24
|
readonly summaryMaxChars: number;
|
|
26
25
|
readonly gateSubmitOutcome?: string;
|
|
26
|
+
readonly workspace?: StepWorkspaceBinding;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
29
|
export type ExtractedChildPolicy = {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { dirname, resolve } from 'node:path';
|
|
1
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
2
2
|
import { SUBAGENT_RUNTIME_NAME_PATTERN } from '../../config/types.ts';
|
|
3
3
|
import {
|
|
4
4
|
DEFAULT_CHILD_POLICY_ENVIRONMENT,
|
|
@@ -19,18 +19,17 @@ const POLICY_KEYS: ReadonlySet<string> = new Set([
|
|
|
19
19
|
'runId',
|
|
20
20
|
'stepId',
|
|
21
21
|
'stepTitle',
|
|
22
|
+
'cwd',
|
|
22
23
|
'policyDigest',
|
|
23
24
|
'capabilityPath',
|
|
24
25
|
'capabilityToken',
|
|
25
26
|
'resultPath',
|
|
26
27
|
'permissions',
|
|
27
|
-
'approvedBashCommands',
|
|
28
|
-
'repositoryCwd',
|
|
29
|
-
'bootstrapCwd',
|
|
30
28
|
'outcomes',
|
|
31
29
|
'pauseOutcomes',
|
|
32
30
|
'summaryMaxChars',
|
|
33
31
|
'gateSubmitOutcome',
|
|
32
|
+
'workspace',
|
|
34
33
|
]);
|
|
35
34
|
|
|
36
35
|
type RequiredStringField =
|
|
@@ -40,6 +39,7 @@ type RequiredStringField =
|
|
|
40
39
|
| 'runId'
|
|
41
40
|
| 'stepId'
|
|
42
41
|
| 'stepTitle'
|
|
42
|
+
| 'cwd'
|
|
43
43
|
| 'policyDigest'
|
|
44
44
|
| 'capabilityPath'
|
|
45
45
|
| 'capabilityToken'
|
|
@@ -85,6 +85,7 @@ type IdentityAndPaths = Pick<
|
|
|
85
85
|
| 'runId'
|
|
86
86
|
| 'stepId'
|
|
87
87
|
| 'stepTitle'
|
|
88
|
+
| 'cwd'
|
|
88
89
|
| 'policyDigest'
|
|
89
90
|
| 'capabilityPath'
|
|
90
91
|
| 'capabilityToken'
|
|
@@ -101,12 +102,16 @@ const parseIdentityAndPaths = (
|
|
|
101
102
|
const runId = requiredString(value, 'runId');
|
|
102
103
|
const stepId = requiredString(value, 'stepId');
|
|
103
104
|
const stepTitle = requiredString(value, 'stepTitle');
|
|
105
|
+
const cwd = requiredString(value, 'cwd');
|
|
104
106
|
const policyDigest = requiredString(value, 'policyDigest');
|
|
105
107
|
const capabilityPath = requiredString(value, 'capabilityPath');
|
|
106
108
|
const capabilityToken = requiredString(value, 'capabilityToken');
|
|
107
109
|
const resultPath = requiredString(value, 'resultPath');
|
|
108
110
|
|
|
109
111
|
if (value.version !== 1) throw new Error('unsupported child policy version');
|
|
112
|
+
if (!isAbsolute(cwd)) {
|
|
113
|
+
throw new Error('child policy cwd must be an absolute path');
|
|
114
|
+
}
|
|
110
115
|
if (!POLICY_DIGEST_PATTERN.test(policyDigest)) {
|
|
111
116
|
throw new Error('child policy digest is invalid');
|
|
112
117
|
}
|
|
@@ -137,6 +142,7 @@ const parseIdentityAndPaths = (
|
|
|
137
142
|
runId,
|
|
138
143
|
stepId,
|
|
139
144
|
stepTitle,
|
|
145
|
+
cwd,
|
|
140
146
|
policyDigest,
|
|
141
147
|
capabilityPath,
|
|
142
148
|
capabilityToken,
|
|
@@ -1,52 +1,41 @@
|
|
|
1
|
-
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
2
1
|
import type { ChildStepPolicy } from './child-policy-types.ts';
|
|
3
2
|
import type { SubagentChildRuntimeDependencies } from './child-runtime-types.ts';
|
|
4
3
|
|
|
5
|
-
const FILE_MUTATION_TOOLS: ReadonlySet<string> = new Set(['edit', 'write']);
|
|
6
|
-
|
|
7
|
-
const errorCode = (error: unknown): unknown =>
|
|
8
|
-
error !== null && typeof error === 'object' && 'code' in error
|
|
9
|
-
? error.code
|
|
10
|
-
: undefined;
|
|
11
|
-
|
|
12
|
-
const pathIsInside = (root: string, candidate: string): boolean => {
|
|
13
|
-
const relativePath = relative(root, candidate);
|
|
14
|
-
return (
|
|
15
|
-
relativePath !== '..' &&
|
|
16
|
-
!relativePath.startsWith(`..${sep}`) &&
|
|
17
|
-
!isAbsolute(relativePath)
|
|
18
|
-
);
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
const nearestCanonicalAncestor = (
|
|
22
|
-
path: string,
|
|
23
|
-
dependencies: SubagentChildRuntimeDependencies,
|
|
24
|
-
): string | undefined => {
|
|
25
|
-
let candidate = path;
|
|
26
|
-
while (true) {
|
|
27
|
-
try {
|
|
28
|
-
dependencies.fileSystem.inspect(candidate);
|
|
29
|
-
} catch (error) {
|
|
30
|
-
if (errorCode(error) !== 'ENOENT') return undefined;
|
|
31
|
-
const parent = dirname(candidate);
|
|
32
|
-
if (parent === candidate) return undefined;
|
|
33
|
-
candidate = parent;
|
|
34
|
-
continue;
|
|
35
|
-
}
|
|
36
|
-
try {
|
|
37
|
-
return dependencies.fileSystem.realPath(candidate);
|
|
38
|
-
} catch {
|
|
39
|
-
return undefined;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
|
-
|
|
44
4
|
type VerifyChildCapabilityOptions = {
|
|
45
5
|
readonly policy: ChildStepPolicy;
|
|
46
6
|
readonly childAgent: string;
|
|
47
7
|
readonly dependencies: SubagentChildRuntimeDependencies;
|
|
48
8
|
};
|
|
49
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Verifies that the delegated child started in the workflow run's captured
|
|
12
|
+
* working directory.
|
|
13
|
+
*
|
|
14
|
+
* @throws When either directory cannot be resolved or they differ.
|
|
15
|
+
*/
|
|
16
|
+
export const verifyChildWorkingDirectory = (
|
|
17
|
+
policy: ChildStepPolicy,
|
|
18
|
+
dependencies: SubagentChildRuntimeDependencies,
|
|
19
|
+
): void => {
|
|
20
|
+
let expected: string;
|
|
21
|
+
let actual: string;
|
|
22
|
+
try {
|
|
23
|
+
expected = dependencies.fileSystem.realPath(policy.cwd);
|
|
24
|
+
actual = dependencies.fileSystem.realPath(
|
|
25
|
+
dependencies.currentWorkingDirectory(),
|
|
26
|
+
);
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'child working directory does not match the delegated workflow policy',
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (expected !== policy.cwd || actual !== expected) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
'child working directory does not match the delegated workflow policy',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
50
39
|
/**
|
|
51
40
|
* Verifies and consumes the one-time capability for a delegated child.
|
|
52
41
|
*
|
|
@@ -108,49 +97,3 @@ export const writeChildResult = ({
|
|
|
108
97
|
throw error;
|
|
109
98
|
}
|
|
110
99
|
};
|
|
111
|
-
|
|
112
|
-
type RepositoryMutationOptions = {
|
|
113
|
-
readonly toolName: string;
|
|
114
|
-
readonly input: Readonly<Record<string, unknown>>;
|
|
115
|
-
readonly policy: ChildStepPolicy;
|
|
116
|
-
readonly dependencies: SubagentChildRuntimeDependencies;
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Returns a policy error when a file mutation escapes the reviewed repository.
|
|
121
|
-
*/
|
|
122
|
-
export const repositoryMutationError = ({
|
|
123
|
-
toolName,
|
|
124
|
-
input,
|
|
125
|
-
policy,
|
|
126
|
-
dependencies,
|
|
127
|
-
}: RepositoryMutationOptions): string | undefined => {
|
|
128
|
-
if (!policy.repositoryCwd || !FILE_MUTATION_TOOLS.has(toolName)) return;
|
|
129
|
-
if (typeof input.path !== 'string' || !input.path.trim()) {
|
|
130
|
-
return `${toolName} must name a path inside the reviewed repository root`;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const candidate = resolve(dependencies.currentWorkingDirectory(), input.path);
|
|
134
|
-
const root = resolve(policy.repositoryCwd);
|
|
135
|
-
if (!pathIsInside(root, candidate)) {
|
|
136
|
-
return `${toolName} path is outside the reviewed repository root "${policy.repositoryCwd}"`;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
let canonicalRoot: string;
|
|
140
|
-
try {
|
|
141
|
-
if (!dependencies.fileSystem.stat(root).isDirectory()) {
|
|
142
|
-
throw new Error('not a directory');
|
|
143
|
-
}
|
|
144
|
-
canonicalRoot = dependencies.fileSystem.realPath(root);
|
|
145
|
-
} catch {
|
|
146
|
-
return `reviewed repository root is not an existing directory: ${policy.repositoryCwd}`;
|
|
147
|
-
}
|
|
148
|
-
const canonicalAncestor = nearestCanonicalAncestor(candidate, dependencies);
|
|
149
|
-
if (
|
|
150
|
-
canonicalAncestor === undefined ||
|
|
151
|
-
!pathIsInside(canonicalRoot, canonicalAncestor)
|
|
152
|
-
) {
|
|
153
|
-
return `${toolName} path is outside the reviewed repository root "${policy.repositoryCwd}"`;
|
|
154
|
-
}
|
|
155
|
-
return;
|
|
156
|
-
};
|