@wichayutdew/pi-workflows 0.3.0 → 1.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 +53 -39
- package/dist/index.js +5754 -4794
- package/package.json +1 -1
- package/src/command-names.ts +7 -1
- package/src/commands.ts +126 -52
- package/src/config/catalog.ts +162 -0
- package/src/config/ceiling.ts +116 -116
- package/src/config/command-conflicts.ts +4 -4
- package/src/config/diagnostics.ts +28 -0
- package/src/config/load-settings.ts +37 -0
- package/src/config/load-types.ts +53 -0
- package/src/config/load-workflows.ts +135 -0
- package/src/config/load.ts +43 -311
- package/src/config/types.ts +137 -135
- package/src/config/validate.ts +12 -1261
- package/src/config/validation/permissions.ts +291 -0
- package/src/config/validation/prompt.ts +19 -0
- package/src/config/validation/settings.ts +122 -0
- package/src/config/validation/shared.ts +129 -0
- package/src/config/validation/shortcut.ts +118 -0
- package/src/config/validation/step.ts +228 -0
- package/src/config/validation/subagent.ts +288 -0
- package/src/config/validation/workflow.ts +156 -0
- package/src/config/yaml.ts +29 -0
- package/src/digest.ts +38 -6
- package/src/engine/checkpoint.ts +13 -9
- package/src/engine/create-run.ts +42 -0
- package/src/engine/gate-transitions.ts +184 -0
- package/src/engine/reconciliation-history.ts +88 -0
- package/src/engine/resume.ts +19 -7
- package/src/engine/run-advance.ts +122 -0
- package/src/engine/run-lifecycle.ts +124 -0
- package/src/engine/run-reconciliation.ts +116 -0
- package/src/engine/run-validation.ts +128 -0
- package/src/engine/state-types.ts +64 -0
- package/src/engine/state.ts +10 -193
- package/src/engine/transition-helpers.ts +28 -0
- package/src/engine/transition-types.ts +8 -0
- package/src/engine/transitions.ts +17 -471
- package/src/harness/action-context.ts +181 -0
- package/src/harness/catalog.ts +45 -0
- package/src/harness/context-idle.ts +21 -0
- package/src/harness/core-actions.ts +299 -0
- package/src/harness/delegation-control-actions.ts +250 -0
- package/src/harness/delegation-failure.ts +215 -0
- package/src/harness/delegation-plan.ts +219 -0
- package/src/harness/delegation-recovery-validation.ts +159 -0
- package/src/harness/delegation-response-actions.ts +295 -0
- package/src/harness/delegation-retry-policy.ts +118 -0
- package/src/harness/dependencies.ts +165 -0
- package/src/harness/gate-submission-action.ts +154 -0
- package/src/harness/lifecycle-actions.ts +154 -0
- package/src/harness/pause-actions.ts +120 -0
- package/src/harness/plannotator-result-actions.ts +123 -0
- package/src/harness/prompt-gate-actions.ts +270 -0
- package/src/harness/resume-action.ts +261 -0
- package/src/harness/start-actions.ts +183 -0
- package/src/harness/status-actions.ts +191 -0
- package/src/harness/step-execution-actions.ts +283 -0
- package/src/harness/types.ts +76 -0
- package/src/harness.ts +236 -2303
- package/src/index.ts +73 -11
- package/src/integrations/plannotator-requests.ts +127 -0
- package/src/integrations/plannotator-responses.ts +141 -0
- package/src/integrations/plannotator-types.ts +44 -0
- package/src/integrations/plannotator.ts +16 -235
- package/src/integrations/prompt-gate.ts +17 -10
- package/src/integrations/subagents/child-policy-envelope.ts +119 -0
- package/src/integrations/subagents/child-policy-paths.ts +66 -0
- package/src/integrations/subagents/child-policy-sections.ts +174 -0
- package/src/integrations/subagents/child-policy-types.ts +32 -0
- package/src/integrations/subagents/child-policy-validation.ts +163 -0
- package/src/integrations/subagents/child-runtime-completion.ts +56 -0
- package/src/integrations/subagents/child-runtime-dependencies.ts +47 -0
- package/src/integrations/subagents/child-runtime-files.ts +156 -0
- package/src/integrations/subagents/child-runtime-policy.ts +83 -0
- package/src/integrations/subagents/child-runtime-types.ts +30 -0
- package/src/integrations/subagents/child-runtime.ts +140 -301
- package/src/integrations/subagents/client-delegation.ts +181 -0
- package/src/integrations/subagents/client-messages.ts +66 -0
- package/src/integrations/subagents/client-types.ts +36 -0
- package/src/integrations/subagents/client.ts +127 -219
- package/src/integrations/subagents/delegated-result.ts +31 -0
- package/src/integrations/subagents/diagnostic-format.ts +40 -0
- package/src/integrations/subagents/diagnostic-text.ts +114 -0
- package/src/integrations/subagents/diagnostic-types.ts +77 -0
- package/src/integrations/subagents/diagnostics.ts +24 -977
- package/src/integrations/subagents/failure-correlation.ts +235 -0
- package/src/integrations/subagents/failure-transcript.ts +192 -0
- package/src/integrations/subagents/hidden-bash-failure.ts +98 -0
- package/src/integrations/subagents/protocol-events.ts +27 -0
- package/src/integrations/subagents/protocol.ts +30 -375
- package/src/integrations/subagents/replay-audit.ts +147 -0
- package/src/integrations/subagents/replay-safety.ts +82 -0
- package/src/integrations/subagents/session-diagnostics.ts +258 -0
- package/src/policy/approved-command-extraction.ts +71 -0
- package/src/policy/approved-commands.ts +15 -432
- package/src/policy/bash-authorization.ts +86 -0
- package/src/policy/bash-read-only.ts +161 -0
- package/src/policy/bash-types.ts +20 -0
- package/src/policy/bash.ts +4 -346
- package/src/policy/completion-batch.ts +21 -16
- package/src/policy/immutable-input.ts +5 -2
- package/src/policy/mcp-authorization.ts +71 -0
- package/src/policy/restricted-command.ts +141 -0
- package/src/policy/restricted-git.ts +30 -0
- package/src/policy/reviewed-artifact.ts +97 -0
- package/src/policy/reviewed-command-safety.ts +150 -0
- package/src/policy/reviewed-command-shape.ts +49 -0
- package/src/policy/reviewed-repository-contract.ts +138 -0
- package/src/policy/reviewed-repository-cwd.ts +102 -0
- package/src/policy/tool-call-authorization.ts +58 -0
- package/src/policy/tool-selection.ts +72 -0
- package/src/policy/tool-types.ts +14 -0
- package/src/policy/tools.ts +11 -150
- package/src/preflight.ts +96 -61
- package/src/prompt/main-workflow-notice.ts +41 -0
- package/src/prompt/retry-task.ts +83 -0
- package/src/prompt/step-contract.ts +101 -0
- package/src/prompt/step-sections.ts +75 -0
- package/src/prompt/step-task.ts +156 -0
- package/src/prompt/template.ts +81 -0
- package/src/prompt.ts +10 -254
- package/src/runtime/completion-tool.ts +3 -0
- package/src/runtime/main-step-completion.ts +78 -0
- package/src/runtime/main-step-lifecycle.ts +37 -0
- package/src/runtime/main-step-policy.ts +84 -0
- package/src/runtime/main-step-runtime-types.ts +64 -0
- package/src/runtime/main-step-runtime.ts +129 -200
- package/src/runtime/main-step-state.ts +85 -0
- package/src/runtime/serial-task-queue.ts +48 -13
- package/src/runtime/step-result.ts +48 -27
- package/src/workflow-list.ts +15 -6
- package/src/workflow-status/format-status.ts +52 -0
- package/src/workflow-status/formatting.ts +178 -0
- package/src/workflow-status/layout.ts +109 -0
- package/src/workflow-status/render-board.ts +101 -0
- package/src/workflow-status/render-path.ts +123 -0
- package/src/workflow-status/render-summary.ts +144 -0
- package/src/workflow-status/types.ts +40 -0
- package/src/workflow-status/view.ts +273 -0
- package/src/workflow-status.ts +20 -820
|
@@ -6,24 +6,31 @@ const PAUSE = 'Pause workflow';
|
|
|
6
6
|
|
|
7
7
|
export type PromptGateReviewResult =
|
|
8
8
|
| {
|
|
9
|
-
status: 'resolved';
|
|
10
|
-
approved: boolean;
|
|
11
|
-
feedback: string;
|
|
9
|
+
readonly status: 'resolved';
|
|
10
|
+
readonly approved: boolean;
|
|
11
|
+
readonly feedback: string;
|
|
12
12
|
}
|
|
13
13
|
| {
|
|
14
|
-
status: 'dismissed';
|
|
14
|
+
readonly status: 'dismissed';
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
const selectionOptions = (
|
|
18
|
+
signal: AbortSignal | undefined,
|
|
19
|
+
): Array<{ signal: AbortSignal }> => (signal ? [{ signal }] : []);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Presents a local workflow gate and returns the reviewer's normalized choice.
|
|
23
|
+
*/
|
|
24
|
+
export const requestPromptGateReview = async (
|
|
18
25
|
ui: ExtensionUIContext,
|
|
19
26
|
title: string,
|
|
20
27
|
artifact: string,
|
|
21
28
|
signal?: AbortSignal,
|
|
22
|
-
): Promise<PromptGateReviewResult> {
|
|
29
|
+
): Promise<PromptGateReviewResult> => {
|
|
23
30
|
const choice = await ui.select(
|
|
24
31
|
`${title}\n\n${artifact}`,
|
|
25
32
|
[APPROVE, REQUEST_CHANGES, PAUSE],
|
|
26
|
-
...(signal
|
|
33
|
+
...selectionOptions(signal),
|
|
27
34
|
);
|
|
28
35
|
|
|
29
36
|
if (choice === APPROVE) {
|
|
@@ -36,14 +43,14 @@ export async function requestPromptGateReview(
|
|
|
36
43
|
let feedback = await ui.input(
|
|
37
44
|
'Workflow review feedback',
|
|
38
45
|
'Describe the required changes',
|
|
39
|
-
...(signal
|
|
46
|
+
...selectionOptions(signal),
|
|
40
47
|
);
|
|
41
48
|
while (feedback !== undefined && !feedback.trim()) {
|
|
42
49
|
ui.notify('Feedback cannot be empty', 'warning');
|
|
43
50
|
feedback = await ui.input(
|
|
44
51
|
'Workflow review feedback',
|
|
45
52
|
'Describe the required changes',
|
|
46
|
-
...(signal
|
|
53
|
+
...selectionOptions(signal),
|
|
47
54
|
);
|
|
48
55
|
}
|
|
49
56
|
if (feedback === undefined) return { status: 'dismissed' };
|
|
@@ -52,4 +59,4 @@ export async function requestPromptGateReview(
|
|
|
52
59
|
approved: false,
|
|
53
60
|
feedback: feedback.trim(),
|
|
54
61
|
};
|
|
55
|
-
}
|
|
62
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
2
|
+
import { DEFAULT_CHILD_POLICY_ENVIRONMENT } from './child-policy-paths.ts';
|
|
3
|
+
import type { ChildPolicyEnvironment } from './child-policy-paths.ts';
|
|
4
|
+
import type {
|
|
5
|
+
ChildStepPolicy,
|
|
6
|
+
ExtractedChildPolicy,
|
|
7
|
+
} from './child-policy-types.ts';
|
|
8
|
+
import { parseChildPolicy } from './child-policy-validation.ts';
|
|
9
|
+
|
|
10
|
+
const CHILD_POLICY_OPEN = '<pi-workflows-policy-v1>';
|
|
11
|
+
const CHILD_POLICY_CLOSE = '</pi-workflows-policy-v1>';
|
|
12
|
+
const UPSTREAM_TASK_PREFIX = 'Task: ';
|
|
13
|
+
const UPSTREAM_TASK_FILE_OPEN = '<file name="';
|
|
14
|
+
const UPSTREAM_TASK_FILE_HEADER_CLOSE = '">\n';
|
|
15
|
+
const UPSTREAM_TASK_FILE_CLOSE = '\n</file>\n';
|
|
16
|
+
const UPSTREAM_TASK_DIRECTORY_PREFIX = 'pi-subagent-';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Encodes a validated child policy into the delegated task envelope.
|
|
20
|
+
*/
|
|
21
|
+
export const encodeChildPolicy = (policy: ChildStepPolicy): string => {
|
|
22
|
+
const encoded = Buffer.from(JSON.stringify(policy), 'utf8').toString(
|
|
23
|
+
'base64url',
|
|
24
|
+
);
|
|
25
|
+
return `${CHILD_POLICY_OPEN}${encoded}${CHILD_POLICY_CLOSE}`;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type UnwrapTaskFileOptions = {
|
|
29
|
+
readonly text: string;
|
|
30
|
+
readonly environment: ChildPolicyEnvironment;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const unwrapTaskFile = ({
|
|
34
|
+
text,
|
|
35
|
+
environment,
|
|
36
|
+
}: UnwrapTaskFileOptions): string | undefined => {
|
|
37
|
+
if (
|
|
38
|
+
!text.startsWith(UPSTREAM_TASK_FILE_OPEN) ||
|
|
39
|
+
!text.endsWith(UPSTREAM_TASK_FILE_CLOSE)
|
|
40
|
+
) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const pathStart = UPSTREAM_TASK_FILE_OPEN.length;
|
|
45
|
+
const headerEnd = text.indexOf(UPSTREAM_TASK_FILE_HEADER_CLOSE, pathStart);
|
|
46
|
+
if (headerEnd === -1) return undefined;
|
|
47
|
+
|
|
48
|
+
const taskFilePath = text.slice(pathStart, headerEnd);
|
|
49
|
+
const taskDirectory = dirname(resolve(taskFilePath));
|
|
50
|
+
const isExpectedTaskFile =
|
|
51
|
+
basename(taskFilePath) === 'task.md' &&
|
|
52
|
+
basename(taskDirectory).startsWith(UPSTREAM_TASK_DIRECTORY_PREFIX) &&
|
|
53
|
+
dirname(taskDirectory) === resolve(environment.temporaryDirectory());
|
|
54
|
+
if (!isExpectedTaskFile) return undefined;
|
|
55
|
+
|
|
56
|
+
const bodyStart = headerEnd + UPSTREAM_TASK_FILE_HEADER_CLOSE.length;
|
|
57
|
+
const body = text.slice(bodyStart, -UPSTREAM_TASK_FILE_CLOSE.length);
|
|
58
|
+
if (!body.startsWith(`${UPSTREAM_TASK_PREFIX}${CHILD_POLICY_OPEN}`)) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
return body.slice(UPSTREAM_TASK_PREFIX.length);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type UnwrapUpstreamTaskOptions = {
|
|
65
|
+
readonly text: string;
|
|
66
|
+
readonly environment: ChildPolicyEnvironment;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const unwrapUpstreamTask = ({
|
|
70
|
+
text,
|
|
71
|
+
environment,
|
|
72
|
+
}: UnwrapUpstreamTaskOptions): string | undefined => {
|
|
73
|
+
if (text.startsWith(CHILD_POLICY_OPEN)) return text;
|
|
74
|
+
if (text.startsWith(`${UPSTREAM_TASK_PREFIX}${CHILD_POLICY_OPEN}`)) {
|
|
75
|
+
return text.slice(UPSTREAM_TASK_PREFIX.length);
|
|
76
|
+
}
|
|
77
|
+
return unwrapTaskFile({ text, environment });
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const decodePolicy = (encoded: string): unknown => {
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
|
|
83
|
+
} catch {
|
|
84
|
+
throw new Error('delegated task child policy cannot be decoded');
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Extracts and validates a child policy from a supported delegated task shape.
|
|
90
|
+
*
|
|
91
|
+
* @returns The validated policy and task, or `undefined` for ordinary input.
|
|
92
|
+
* @throws When a policy envelope is present but malformed.
|
|
93
|
+
*/
|
|
94
|
+
export const extractChildPolicy = (
|
|
95
|
+
text: string,
|
|
96
|
+
environment: ChildPolicyEnvironment = DEFAULT_CHILD_POLICY_ENVIRONMENT,
|
|
97
|
+
): ExtractedChildPolicy | undefined => {
|
|
98
|
+
const taskWithPolicy = unwrapUpstreamTask({ text, environment });
|
|
99
|
+
if (taskWithPolicy === undefined) return undefined;
|
|
100
|
+
|
|
101
|
+
const payloadStart = CHILD_POLICY_OPEN.length;
|
|
102
|
+
const payloadEnd = taskWithPolicy.indexOf(CHILD_POLICY_CLOSE, payloadStart);
|
|
103
|
+
const hasNestedEnvelope =
|
|
104
|
+
taskWithPolicy.indexOf(CHILD_POLICY_OPEN, payloadStart) !== -1;
|
|
105
|
+
if (payloadEnd === -1 || hasNestedEnvelope) {
|
|
106
|
+
throw new Error('delegated task contains an invalid child policy envelope');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const encoded = taskWithPolicy.slice(payloadStart, payloadEnd);
|
|
110
|
+
const task = taskWithPolicy
|
|
111
|
+
.slice(payloadEnd + CHILD_POLICY_CLOSE.length)
|
|
112
|
+
.trim();
|
|
113
|
+
if (!task) throw new Error('delegated task is empty after policy extraction');
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
policy: parseChildPolicy(decodePolicy(encoded), environment),
|
|
117
|
+
task,
|
|
118
|
+
};
|
|
119
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { tmpdir } from 'node:os';
|
|
2
|
+
import { basename, dirname, relative, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const RESULT_FILE_NAME = 'result.json';
|
|
5
|
+
const CAPABILITY_FILE_NAME = 'capability';
|
|
6
|
+
const RESULT_DIRECTORY_PREFIX = 'pi-workflows-step-';
|
|
7
|
+
|
|
8
|
+
export type ChildPolicyEnvironment = {
|
|
9
|
+
readonly temporaryDirectory: () => string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_CHILD_POLICY_ENVIRONMENT = {
|
|
13
|
+
temporaryDirectory: tmpdir,
|
|
14
|
+
} as const satisfies ChildPolicyEnvironment;
|
|
15
|
+
|
|
16
|
+
type SafeStepFileOptions = {
|
|
17
|
+
readonly path: string;
|
|
18
|
+
readonly expectedName: string;
|
|
19
|
+
readonly environment: ChildPolicyEnvironment;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const isSafeStepFilePath = ({
|
|
23
|
+
path,
|
|
24
|
+
expectedName,
|
|
25
|
+
environment,
|
|
26
|
+
}: SafeStepFileOptions): boolean => {
|
|
27
|
+
const temporaryRoot = resolve(environment.temporaryDirectory());
|
|
28
|
+
const candidate = resolve(path);
|
|
29
|
+
const relativePath = relative(temporaryRoot, candidate);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
relativePath !== '' &&
|
|
33
|
+
!relativePath.startsWith('..') &&
|
|
34
|
+
!relativePath.includes('\0') &&
|
|
35
|
+
basename(candidate) === expectedName &&
|
|
36
|
+
basename(dirname(candidate)).startsWith(RESULT_DIRECTORY_PREFIX)
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Returns whether a delegated result path is confined to its temporary step
|
|
42
|
+
* directory.
|
|
43
|
+
*/
|
|
44
|
+
export const isSafeStepResultPath = (
|
|
45
|
+
path: string,
|
|
46
|
+
environment: ChildPolicyEnvironment = DEFAULT_CHILD_POLICY_ENVIRONMENT,
|
|
47
|
+
): boolean =>
|
|
48
|
+
isSafeStepFilePath({
|
|
49
|
+
path,
|
|
50
|
+
expectedName: RESULT_FILE_NAME,
|
|
51
|
+
environment,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Returns whether a delegated capability path is confined to its temporary
|
|
56
|
+
* step directory.
|
|
57
|
+
*/
|
|
58
|
+
export const isSafeStepCapabilityPath = (
|
|
59
|
+
path: string,
|
|
60
|
+
environment: ChildPolicyEnvironment = DEFAULT_CHILD_POLICY_ENVIRONMENT,
|
|
61
|
+
): boolean =>
|
|
62
|
+
isSafeStepFilePath({
|
|
63
|
+
path,
|
|
64
|
+
expectedName: CAPABILITY_FILE_NAME,
|
|
65
|
+
environment,
|
|
66
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
2
|
+
import type { StepPermissions } from '../../config/types.ts';
|
|
3
|
+
import type { ChildStepPolicy } from './child-policy-types.ts';
|
|
4
|
+
|
|
5
|
+
type PolicySections = Pick<
|
|
6
|
+
ChildStepPolicy,
|
|
7
|
+
| 'permissions'
|
|
8
|
+
| 'approvedBashCommands'
|
|
9
|
+
| 'repositoryCwd'
|
|
10
|
+
| 'bootstrapCwd'
|
|
11
|
+
| 'outcomes'
|
|
12
|
+
| 'pauseOutcomes'
|
|
13
|
+
| 'summaryMaxChars'
|
|
14
|
+
| 'gateSubmitOutcome'
|
|
15
|
+
>;
|
|
16
|
+
|
|
17
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
18
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
19
|
+
|
|
20
|
+
const isStringArray = (value: unknown): value is Array<string> =>
|
|
21
|
+
Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
22
|
+
|
|
23
|
+
const isStepPermissions = (value: unknown): value is StepPermissions => {
|
|
24
|
+
if (!isRecord(value) || !isRecord(value.bash)) return false;
|
|
25
|
+
|
|
26
|
+
const bash = value.bash;
|
|
27
|
+
const bashRules = Array.isArray(bash.allow) ? bash.allow : undefined;
|
|
28
|
+
const isValidMode =
|
|
29
|
+
bash.mode === 'deny' ||
|
|
30
|
+
bash.mode === 'read-only' ||
|
|
31
|
+
bash.mode === 'allow-list' ||
|
|
32
|
+
bash.mode === 'unrestricted';
|
|
33
|
+
const hasValidRules =
|
|
34
|
+
bashRules !== undefined &&
|
|
35
|
+
bashRules.every(
|
|
36
|
+
(rule) =>
|
|
37
|
+
isRecord(rule) &&
|
|
38
|
+
typeof rule.executable === 'string' &&
|
|
39
|
+
isStringArray(rule.argsPrefix),
|
|
40
|
+
);
|
|
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
|
+
));
|
|
51
|
+
const hasValidApprovalShape =
|
|
52
|
+
bash.mode === 'allow-list'
|
|
53
|
+
? (bashRules?.length ?? 0) > 0 ||
|
|
54
|
+
(Array.isArray(bash.approvedSources) && bash.approvedSources.length > 0)
|
|
55
|
+
: bash.approvedSources === undefined;
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
isStringArray(value.tools) &&
|
|
59
|
+
isStringArray(value.mcp) &&
|
|
60
|
+
isStringArray(value.extensions) &&
|
|
61
|
+
isStringArray(value.skills) &&
|
|
62
|
+
isValidMode &&
|
|
63
|
+
hasValidRules &&
|
|
64
|
+
hasValidApprovedSources &&
|
|
65
|
+
hasValidApprovalShape
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const parsePermissions = (
|
|
70
|
+
value: Readonly<Record<string, unknown>>,
|
|
71
|
+
): Pick<PolicySections, 'permissions' | 'approvedBashCommands'> => {
|
|
72
|
+
if (!isStepPermissions(value.permissions)) {
|
|
73
|
+
throw new Error('child policy permissions are invalid');
|
|
74
|
+
}
|
|
75
|
+
const approvedBashCommands = value.approvedBashCommands;
|
|
76
|
+
if (
|
|
77
|
+
approvedBashCommands !== undefined &&
|
|
78
|
+
(!isStringArray(approvedBashCommands) ||
|
|
79
|
+
new Set(approvedBashCommands).size !== approvedBashCommands.length)
|
|
80
|
+
) {
|
|
81
|
+
throw new Error('child policy approved Bash commands are invalid');
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
permissions: value.permissions,
|
|
85
|
+
...(approvedBashCommands === undefined ? {} : { approvedBashCommands }),
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const parseRepositoryPaths = (
|
|
90
|
+
value: Readonly<Record<string, unknown>>,
|
|
91
|
+
): Pick<PolicySections, 'repositoryCwd' | 'bootstrapCwd'> => {
|
|
92
|
+
const repositoryCwd = value.repositoryCwd;
|
|
93
|
+
const bootstrapCwd = value.bootstrapCwd;
|
|
94
|
+
if (
|
|
95
|
+
repositoryCwd !== undefined &&
|
|
96
|
+
(typeof repositoryCwd !== 'string' ||
|
|
97
|
+
!isAbsolute(repositoryCwd) ||
|
|
98
|
+
repositoryCwd.includes('\0'))
|
|
99
|
+
) {
|
|
100
|
+
throw new Error('child policy repository cwd is invalid');
|
|
101
|
+
}
|
|
102
|
+
if (
|
|
103
|
+
bootstrapCwd !== undefined &&
|
|
104
|
+
(typeof bootstrapCwd !== 'string' ||
|
|
105
|
+
!isAbsolute(bootstrapCwd) ||
|
|
106
|
+
bootstrapCwd.includes('\0') ||
|
|
107
|
+
typeof repositoryCwd !== 'string' ||
|
|
108
|
+
resolve(bootstrapCwd) === resolve(repositoryCwd))
|
|
109
|
+
) {
|
|
110
|
+
throw new Error('child policy bootstrap cwd is invalid');
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
...(repositoryCwd === undefined ? {} : { repositoryCwd }),
|
|
114
|
+
...(bootstrapCwd === undefined ? {} : { bootstrapCwd }),
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const parseOutcomes = (
|
|
119
|
+
value: Readonly<Record<string, unknown>>,
|
|
120
|
+
): Pick<
|
|
121
|
+
PolicySections,
|
|
122
|
+
'outcomes' | 'pauseOutcomes' | 'summaryMaxChars' | 'gateSubmitOutcome'
|
|
123
|
+
> => {
|
|
124
|
+
const outcomes = value.outcomes;
|
|
125
|
+
if (
|
|
126
|
+
!isStringArray(outcomes) ||
|
|
127
|
+
outcomes.length === 0 ||
|
|
128
|
+
new Set(outcomes).size !== outcomes.length
|
|
129
|
+
) {
|
|
130
|
+
throw new Error('child policy outcomes are invalid');
|
|
131
|
+
}
|
|
132
|
+
const pauseOutcomes = value.pauseOutcomes;
|
|
133
|
+
if (
|
|
134
|
+
!isStringArray(pauseOutcomes) ||
|
|
135
|
+
new Set(pauseOutcomes).size !== pauseOutcomes.length ||
|
|
136
|
+
pauseOutcomes.some((outcome) => !outcomes.includes(outcome))
|
|
137
|
+
) {
|
|
138
|
+
throw new Error('child policy pause outcomes are invalid');
|
|
139
|
+
}
|
|
140
|
+
const summaryMaxChars = value.summaryMaxChars;
|
|
141
|
+
if (
|
|
142
|
+
typeof summaryMaxChars !== 'number' ||
|
|
143
|
+
!Number.isInteger(summaryMaxChars) ||
|
|
144
|
+
summaryMaxChars < 100 ||
|
|
145
|
+
summaryMaxChars > 50_000
|
|
146
|
+
) {
|
|
147
|
+
throw new Error('child policy summaryMaxChars is invalid');
|
|
148
|
+
}
|
|
149
|
+
const gateSubmitOutcome = value.gateSubmitOutcome;
|
|
150
|
+
if (
|
|
151
|
+
gateSubmitOutcome !== undefined &&
|
|
152
|
+
(typeof gateSubmitOutcome !== 'string' ||
|
|
153
|
+
!outcomes.includes(gateSubmitOutcome))
|
|
154
|
+
) {
|
|
155
|
+
throw new Error('child policy gate outcome is invalid');
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
outcomes,
|
|
159
|
+
pauseOutcomes,
|
|
160
|
+
summaryMaxChars,
|
|
161
|
+
...(gateSubmitOutcome === undefined ? {} : { gateSubmitOutcome }),
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Parses permission, repository, and outcome sections of a child policy.
|
|
167
|
+
*/
|
|
168
|
+
export const parseChildPolicySections = (
|
|
169
|
+
value: Readonly<Record<string, unknown>>,
|
|
170
|
+
): PolicySections => ({
|
|
171
|
+
...parsePermissions(value),
|
|
172
|
+
...parseRepositoryPaths(value),
|
|
173
|
+
...parseOutcomes(value),
|
|
174
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { StepPermissions } from '../../config/types.ts';
|
|
2
|
+
|
|
3
|
+
export type ChildStepPolicy = {
|
|
4
|
+
readonly version: 1;
|
|
5
|
+
readonly requestId: string;
|
|
6
|
+
readonly agent: string;
|
|
7
|
+
readonly workflowId: string;
|
|
8
|
+
readonly runId: string;
|
|
9
|
+
readonly stepId: string;
|
|
10
|
+
readonly stepTitle: string;
|
|
11
|
+
readonly policyDigest: string;
|
|
12
|
+
readonly capabilityPath: string;
|
|
13
|
+
readonly capabilityToken: string;
|
|
14
|
+
readonly resultPath: string;
|
|
15
|
+
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
|
+
readonly outcomes: ReadonlyArray<string>;
|
|
23
|
+
/** Outcomes that pause instead of advancing to another workflow step. */
|
|
24
|
+
readonly pauseOutcomes: ReadonlyArray<string>;
|
|
25
|
+
readonly summaryMaxChars: number;
|
|
26
|
+
readonly gateSubmitOutcome?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ExtractedChildPolicy = {
|
|
30
|
+
readonly policy: ChildStepPolicy;
|
|
31
|
+
readonly task: string;
|
|
32
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { dirname, resolve } from 'node:path';
|
|
2
|
+
import { SUBAGENT_RUNTIME_NAME_PATTERN } from '../../config/types.ts';
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_CHILD_POLICY_ENVIRONMENT,
|
|
5
|
+
isSafeStepCapabilityPath,
|
|
6
|
+
isSafeStepResultPath,
|
|
7
|
+
} from './child-policy-paths.ts';
|
|
8
|
+
import type { ChildPolicyEnvironment } from './child-policy-paths.ts';
|
|
9
|
+
import { parseChildPolicySections } from './child-policy-sections.ts';
|
|
10
|
+
import type { ChildStepPolicy } from './child-policy-types.ts';
|
|
11
|
+
|
|
12
|
+
const POLICY_DIGEST_PATTERN = /^[a-f0-9]{64}$/;
|
|
13
|
+
const CAPABILITY_TOKEN_PATTERN = /^[a-f0-9]{64}$/;
|
|
14
|
+
const POLICY_KEYS: ReadonlySet<string> = new Set([
|
|
15
|
+
'version',
|
|
16
|
+
'requestId',
|
|
17
|
+
'agent',
|
|
18
|
+
'workflowId',
|
|
19
|
+
'runId',
|
|
20
|
+
'stepId',
|
|
21
|
+
'stepTitle',
|
|
22
|
+
'policyDigest',
|
|
23
|
+
'capabilityPath',
|
|
24
|
+
'capabilityToken',
|
|
25
|
+
'resultPath',
|
|
26
|
+
'permissions',
|
|
27
|
+
'approvedBashCommands',
|
|
28
|
+
'repositoryCwd',
|
|
29
|
+
'bootstrapCwd',
|
|
30
|
+
'outcomes',
|
|
31
|
+
'pauseOutcomes',
|
|
32
|
+
'summaryMaxChars',
|
|
33
|
+
'gateSubmitOutcome',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
type RequiredStringField =
|
|
37
|
+
| 'requestId'
|
|
38
|
+
| 'agent'
|
|
39
|
+
| 'workflowId'
|
|
40
|
+
| 'runId'
|
|
41
|
+
| 'stepId'
|
|
42
|
+
| 'stepTitle'
|
|
43
|
+
| 'policyDigest'
|
|
44
|
+
| 'capabilityPath'
|
|
45
|
+
| 'capabilityToken'
|
|
46
|
+
| 'resultPath';
|
|
47
|
+
|
|
48
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
49
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
50
|
+
|
|
51
|
+
const requiredString = (
|
|
52
|
+
value: Readonly<Record<string, unknown>>,
|
|
53
|
+
field: RequiredStringField,
|
|
54
|
+
): string => {
|
|
55
|
+
const candidate = value[field];
|
|
56
|
+
if (typeof candidate !== 'string' || !candidate) {
|
|
57
|
+
throw new Error(`child policy ${field} must be a non-empty string`);
|
|
58
|
+
}
|
|
59
|
+
return candidate;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Returns whether a name follows the runtime naming contract used by
|
|
64
|
+
* pi-subagents.
|
|
65
|
+
*/
|
|
66
|
+
export const isSubagentRuntimeName = (
|
|
67
|
+
name: string | undefined,
|
|
68
|
+
): name is string => Boolean(name && SUBAGENT_RUNTIME_NAME_PATTERN.test(name));
|
|
69
|
+
|
|
70
|
+
const rejectUnknownProperties = (
|
|
71
|
+
value: Readonly<Record<string, unknown>>,
|
|
72
|
+
): void => {
|
|
73
|
+
const unknownKey = Object.keys(value).find((key) => !POLICY_KEYS.has(key));
|
|
74
|
+
if (unknownKey) {
|
|
75
|
+
throw new Error(`child policy has unknown property "${unknownKey}"`);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type IdentityAndPaths = Pick<
|
|
80
|
+
ChildStepPolicy,
|
|
81
|
+
| 'version'
|
|
82
|
+
| 'requestId'
|
|
83
|
+
| 'agent'
|
|
84
|
+
| 'workflowId'
|
|
85
|
+
| 'runId'
|
|
86
|
+
| 'stepId'
|
|
87
|
+
| 'stepTitle'
|
|
88
|
+
| 'policyDigest'
|
|
89
|
+
| 'capabilityPath'
|
|
90
|
+
| 'capabilityToken'
|
|
91
|
+
| 'resultPath'
|
|
92
|
+
>;
|
|
93
|
+
|
|
94
|
+
const parseIdentityAndPaths = (
|
|
95
|
+
value: Readonly<Record<string, unknown>>,
|
|
96
|
+
environment: ChildPolicyEnvironment,
|
|
97
|
+
): IdentityAndPaths => {
|
|
98
|
+
const requestId = requiredString(value, 'requestId');
|
|
99
|
+
const agent = requiredString(value, 'agent');
|
|
100
|
+
const workflowId = requiredString(value, 'workflowId');
|
|
101
|
+
const runId = requiredString(value, 'runId');
|
|
102
|
+
const stepId = requiredString(value, 'stepId');
|
|
103
|
+
const stepTitle = requiredString(value, 'stepTitle');
|
|
104
|
+
const policyDigest = requiredString(value, 'policyDigest');
|
|
105
|
+
const capabilityPath = requiredString(value, 'capabilityPath');
|
|
106
|
+
const capabilityToken = requiredString(value, 'capabilityToken');
|
|
107
|
+
const resultPath = requiredString(value, 'resultPath');
|
|
108
|
+
|
|
109
|
+
if (value.version !== 1) throw new Error('unsupported child policy version');
|
|
110
|
+
if (!POLICY_DIGEST_PATTERN.test(policyDigest)) {
|
|
111
|
+
throw new Error('child policy digest is invalid');
|
|
112
|
+
}
|
|
113
|
+
if (!isSubagentRuntimeName(agent)) {
|
|
114
|
+
throw new Error('child policy agent is not a valid subagent runtime name');
|
|
115
|
+
}
|
|
116
|
+
if (!CAPABILITY_TOKEN_PATTERN.test(capabilityToken)) {
|
|
117
|
+
throw new Error('child policy capability token is invalid');
|
|
118
|
+
}
|
|
119
|
+
if (!isSafeStepCapabilityPath(capabilityPath, environment)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
'child policy capability path is outside its temporary directory',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (!isSafeStepResultPath(resultPath, environment)) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
'child policy result path is outside its temporary directory',
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (dirname(resolve(capabilityPath)) !== dirname(resolve(resultPath))) {
|
|
130
|
+
throw new Error('child policy files must share one temporary directory');
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
version: 1,
|
|
134
|
+
requestId,
|
|
135
|
+
agent,
|
|
136
|
+
workflowId,
|
|
137
|
+
runId,
|
|
138
|
+
stepId,
|
|
139
|
+
stepTitle,
|
|
140
|
+
policyDigest,
|
|
141
|
+
capabilityPath,
|
|
142
|
+
capabilityToken,
|
|
143
|
+
resultPath,
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Validates untrusted delegated policy data and returns its narrow domain type.
|
|
149
|
+
*
|
|
150
|
+
* @throws When the value does not satisfy the child policy contract.
|
|
151
|
+
*/
|
|
152
|
+
export const parseChildPolicy = (
|
|
153
|
+
value: unknown,
|
|
154
|
+
environment: ChildPolicyEnvironment = DEFAULT_CHILD_POLICY_ENVIRONMENT,
|
|
155
|
+
): ChildStepPolicy => {
|
|
156
|
+
if (!isRecord(value)) throw new Error('child policy must be an object');
|
|
157
|
+
|
|
158
|
+
rejectUnknownProperties(value);
|
|
159
|
+
return {
|
|
160
|
+
...parseIdentityAndPaths(value, environment),
|
|
161
|
+
...parseChildPolicySections(value),
|
|
162
|
+
};
|
|
163
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { parseDelegatedStepResult } from './delegated-result.ts';
|
|
2
|
+
import type { ChildStepPolicy } from './child-policy-types.ts';
|
|
3
|
+
|
|
4
|
+
export const CHILD_COMPLETION_TOOL = 'structured_output';
|
|
5
|
+
export const CHILD_COORDINATION_TOOLS: ReadonlySet<string> = new Set([
|
|
6
|
+
'contact_supervisor',
|
|
7
|
+
'subagent_supervisor',
|
|
8
|
+
'intercom',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const STRUCTURED_RESULT_KEYS: ReadonlySet<string> = new Set([
|
|
12
|
+
'outcome',
|
|
13
|
+
'summary',
|
|
14
|
+
'artifact',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
18
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validates and unwraps a structured-output tool input for a child policy.
|
|
22
|
+
*/
|
|
23
|
+
export const parseChildStructuredResult = ({
|
|
24
|
+
input,
|
|
25
|
+
policy,
|
|
26
|
+
}: {
|
|
27
|
+
readonly input: unknown;
|
|
28
|
+
readonly policy: ChildStepPolicy;
|
|
29
|
+
}): ReturnType<typeof parseDelegatedStepResult> => {
|
|
30
|
+
if (!isRecord(input)) {
|
|
31
|
+
throw new Error('structured_output input must be an object');
|
|
32
|
+
}
|
|
33
|
+
if (Object.keys(input).length !== 1 || !Object.hasOwn(input, 'value')) {
|
|
34
|
+
throw new Error('structured_output input must contain only value');
|
|
35
|
+
}
|
|
36
|
+
if (!isRecord(input.value)) {
|
|
37
|
+
throw new Error('structured_output value must be an object');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const unknownKey = Object.keys(input.value).find(
|
|
41
|
+
(key) => !STRUCTURED_RESULT_KEYS.has(key),
|
|
42
|
+
);
|
|
43
|
+
if (unknownKey) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`structured_output value has unknown property "${unknownKey}"`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return parseDelegatedStepResult(
|
|
49
|
+
{
|
|
50
|
+
version: 1,
|
|
51
|
+
policyDigest: policy.policyDigest,
|
|
52
|
+
...input.value,
|
|
53
|
+
},
|
|
54
|
+
policy,
|
|
55
|
+
);
|
|
56
|
+
};
|