pi-harness-delegate 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Input validation for values that end up on a harness's (or `gh`'s) command line.
3
+ *
4
+ * `sessionId`, `model`, and `pr` all reach `spawn()`/`pi.exec()` argv — no shell, so no shell
5
+ * injection, but a value starting with `-` would be parsed by the target CLI as a *flag* (argument
6
+ * injection: e.g. a `sessionId` of `--dangerously-bypass-…` or a `pr` of `--repo=evil/x`). These
7
+ * values are model-settable on the `delegate` tool, and the model's context is attacker-influenceable,
8
+ * so they're validated once at the shared `delegate()` entry (both tool and `/delegate` paths) and
9
+ * up front on fan-out — never left to each harness's `buildArgs`.
10
+ */
11
+
12
+ import { realpathSync } from 'node:fs';
13
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
14
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
15
+
16
+ const SESSION_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
17
+ const PR_NUMBER_RE = /^\d{1,10}$/;
18
+ const PR_SHORTHAND_RE = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}#\d{1,10}$/;
19
+ // http(s)://<host>/<owner>/<repo>/pull/<n>[/files|?…|#…] — any host (GitHub Enterprise), but no
20
+ // userinfo (`user:pass@`) and nothing that isn't actually a pull-request URL.
21
+ const PR_URL_RE = /^https?:\/\/[^/@\s]+\/[^/\s]+\/[^/\s]+\/pull\/\d{1,10}(?:[/?#]\S*)?$/;
22
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: rejecting control characters is the point
23
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/;
24
+
25
+ /** Returns an error message, or null when `id` is an acceptable session id. */
26
+ export function sessionIdError(id: string): string | null {
27
+ if (id.startsWith('-') || !SESSION_ID_RE.test(id))
28
+ return `invalid sessionId ${JSON.stringify(id.slice(0, 60))} — expected 1-128 chars of [A-Za-z0-9._:-], not starting with "-" (use the session id from a previous run's details)`;
29
+ return null;
30
+ }
31
+
32
+ /** Returns an error message, or null when `model` is an acceptable model name. */
33
+ export function modelError(model: string): string | null {
34
+ if (model.length === 0 || model.length > 200 || model.startsWith('-') || CONTROL_RE.test(model))
35
+ return `invalid model ${JSON.stringify(model.slice(0, 60))} — must be a non-empty name, not starting with "-"`;
36
+ return null;
37
+ }
38
+
39
+ /** Returns an error message, or null when `pr` is a PR number, an http(s) pull-request URL
40
+ * (`<host>/<owner>/<repo>/pull/<n>`, no userinfo), or `owner/repo#n`. */
41
+ export function prError(pr: string): string | null {
42
+ const bad = `invalid pr ${JSON.stringify(pr.slice(0, 80))} — expected a PR number, an http(s) PR URL, or owner/repo#123`;
43
+ if (pr.startsWith('-') || CONTROL_RE.test(pr)) return bad;
44
+ if (PR_NUMBER_RE.test(pr) || PR_SHORTHAND_RE.test(pr)) return null;
45
+ if (PR_URL_RE.test(pr)) return null;
46
+ return bad;
47
+ }
48
+
49
+ /**
50
+ * Returns an error message, or null when `dir` is an acceptable extra directory path. Judged on its
51
+ * *resolved* form, which is what actually reaches argv (`mergeAddDirs` resolves every entry against
52
+ * cwd): an absolute path can't be parsed as a flag, so a legitimate relative dir like `-foo`
53
+ * (→ `<cwd>/-foo`) is fine. Empty, oversized, and control-character entries are still rejected.
54
+ */
55
+ export function addDirError(dir: string, cwd: string = process.cwd()): string | null {
56
+ const bad = `invalid addDirs entry ${JSON.stringify(dir.slice(0, 80))} — must be a non-empty path without control characters`;
57
+ if (dir.length === 0 || dir.length > 4096 || CONTROL_RE.test(dir)) return bad;
58
+ const abs = resolve(cwd, dir);
59
+ if (!isAbsolute(abs) || abs.startsWith('-')) return bad;
60
+ return null;
61
+ }
62
+
63
+ export interface ValidatableInputs {
64
+ sessionId?: string;
65
+ model?: string;
66
+ pr?: string;
67
+ addDirs?: string[];
68
+ /** What relative `addDirs` resolve against (the run's cwd). */
69
+ cwd?: string;
70
+ }
71
+
72
+ /** Throws a clear Error on the first invalid argv-bound input; returns normally otherwise. */
73
+ export function validateDelegateInputs(inputs: ValidatableInputs): void {
74
+ const errors = [
75
+ inputs.sessionId !== undefined ? sessionIdError(inputs.sessionId) : null,
76
+ inputs.model !== undefined ? modelError(inputs.model) : null,
77
+ inputs.pr !== undefined ? prError(inputs.pr) : null,
78
+ ...(inputs.addDirs ?? []).map(d => addDirError(d, inputs.cwd)),
79
+ ];
80
+ const first = errors.find((e): e is string => e !== null);
81
+ if (first) throw new Error(first);
82
+ }
83
+
84
+ type ConfirmCtx = Pick<ExtensionContext, 'hasUI'> & { ui?: { confirm?: ExtensionContext['ui']['confirm'] } };
85
+
86
+ /**
87
+ * The one danger-confirmation primitive shared by the tool and command paths: with a UI, ask via
88
+ * `ctx.ui.confirm` (a decline or a throwing dialog counts as "no"); without one there is nobody to
89
+ * ask, so fail closed. Resolves only on an explicit approval; throws the caller's message otherwise.
90
+ */
91
+ async function askDangerConfirmation(
92
+ ctx: ConfirmCtx,
93
+ opts: { task: string; body: string; noUiError: string; declinedError: string },
94
+ ): Promise<void> {
95
+ if (!ctx.hasUI || typeof ctx.ui?.confirm !== 'function') throw new Error(opts.noUiError);
96
+ const task = opts.task.length > 200 ? `${opts.task.slice(0, 199)}…` : opts.task;
97
+ let ok = false;
98
+ try {
99
+ ok = await ctx.ui.confirm('Allow dangerous delegation?', `${opts.body}\n\nTask: ${task}`);
100
+ } catch {
101
+ ok = false;
102
+ }
103
+ if (!ok) throw new Error(opts.declinedError);
104
+ }
105
+
106
+ /**
107
+ * Gate a model-requested `allowDangerous: true` on the `delegate` tool behind a human: a tool
108
+ * param is model-settable (prompt-injection reachable), and `danger` means an unrestricted
109
+ * harness, so the model alone must never be able to grant it. With a UI, ask via
110
+ * `ctx.ui.confirm`; without one there is nobody to ask, so fail closed with a clear error. The
111
+ * `/delegate` command path uses `confirmDangerousCommand` instead.
112
+ */
113
+ export async function confirmDangerousToolCall(
114
+ ctx: ConfirmCtx,
115
+ summary: { harness?: string; mode?: string; task: string },
116
+ ): Promise<void> {
117
+ const target = `${summary.harness ?? 'default harness'} ${summary.mode ?? 'default mode'}`;
118
+ await askDangerConfirmation(ctx, {
119
+ task: summary.task,
120
+ body: `The agent wants to run ${target} with DANGER permission (unrestricted: no sandbox, no approval prompts).`,
121
+ noUiError: `allowDangerous requested for ${target}, but there is no interactive UI to confirm it with — refusing (danger permission needs a human's explicit approval; run it from an interactive session)`,
122
+ declinedError: `allowDangerous for ${target} was declined by the user`,
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Gate a human-typed `/delegate --allow-dangerous` behind the same interactive confirm. A human
128
+ * typed the flag, but it's one token away from an unrestricted run (and also escalates a
129
+ * non-danger template), so it's confirmed once more naming exactly what will run — one prompt
130
+ * covering every harness of a fan-out. Headless sessions never honor it: there's no one to
131
+ * confirm with, so it fails closed exactly like the tool path.
132
+ */
133
+ export async function confirmDangerousCommand(
134
+ ctx: ConfirmCtx,
135
+ summary: { harnesses: string[]; mode: string; task: string },
136
+ ): Promise<void> {
137
+ const n = summary.harnesses.length;
138
+ const names = summary.harnesses.join(', ');
139
+ const target = `${names} ${summary.mode}`;
140
+ await askDangerConfirmation(ctx, {
141
+ task: summary.task,
142
+ body: `--allow-dangerous: run "${summary.mode}" on ${n > 1 ? `all ${n} harnesses (${names})` : names} with DANGER permission — full, unrestricted permissions (no sandbox, no approval prompts). Applies to this invocation only.`,
143
+ noUiError: `--allow-dangerous for ${target} needs interactive confirmation, but there is no UI — refusing (a headless /delegate never runs with danger permission)`,
144
+ declinedError: `--allow-dangerous for ${target} was declined — nothing was run`,
145
+ });
146
+ }
147
+
148
+ /** realpath of `p`, or — when `p` doesn't exist yet — realpath of its nearest existing ancestor
149
+ * with the missing tail re-appended, so a not-yet-created dir under a symlink still resolves
150
+ * through that symlink. */
151
+ function realpathOrAncestor(p: string): string {
152
+ let cur = p;
153
+ const tail: string[] = [];
154
+ for (;;) {
155
+ try {
156
+ return join(realpathSync(cur), ...tail);
157
+ } catch {
158
+ const parent = dirname(cur);
159
+ if (parent === cur) return p;
160
+ tail.unshift(basename(cur));
161
+ cur = parent;
162
+ }
163
+ }
164
+ }
165
+
166
+ /**
167
+ * The model-supplied `addDirs` entries that resolve **outside** `cwd` — after `resolve()` against
168
+ * `cwd` and symlink resolution on both sides, so neither `..` nor a symlink inside the repo
169
+ * pointing out of it escapes. Empty when every entry stays inside the working directory.
170
+ */
171
+ export function addDirsOutsideCwd(cwd: string, addDirs: string[] | undefined): string[] {
172
+ if (!addDirs || addDirs.length === 0) return [];
173
+ const root = realpathOrAncestor(resolve(cwd));
174
+ const out: string[] = [];
175
+ for (const d of addDirs) {
176
+ const real = realpathOrAncestor(resolve(cwd, d));
177
+ const rel = relative(root, real);
178
+ const inside = rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
179
+ if (!inside) out.push(real);
180
+ }
181
+ return out;
182
+ }
183
+
184
+ /**
185
+ * Gate model-requested `addDirs` on the `delegate` tool. `addDirs` widens what the harness may
186
+ * touch — on `codex`/`claude`/`amp` the extra dir is writable on any non-readonly run, and even a
187
+ * `readonly` run can read it — so a model-set entry (prompt-injection reachable, same trust model as
188
+ * `allowDangerous`/`verify`) may only stay inside the working directory on its own. Anything that
189
+ * resolves outside it needs a human's `ctx.ui.confirm`; with no UI, fail closed. Template
190
+ * frontmatter (trusted on-disk config) and the human-typed `/delegate --add-dir` never call this.
191
+ */
192
+ export async function confirmToolAddDirs(
193
+ ctx: Pick<ExtensionContext, 'hasUI' | 'cwd'> & { ui?: { confirm?: ExtensionContext['ui']['confirm'] } },
194
+ addDirs: string[] | undefined,
195
+ ): Promise<void> {
196
+ const outside = addDirsOutsideCwd(ctx.cwd, addDirs);
197
+ if (outside.length === 0) return;
198
+ const list = outside.join(', ');
199
+ if (!ctx.hasUI || typeof ctx.ui?.confirm !== 'function') {
200
+ throw new Error(
201
+ `addDirs outside the working directory requested (${list}), but there is no interactive UI to confirm it with — refusing (extra directories outside the project need a human's explicit approval)`,
202
+ );
203
+ }
204
+ let ok = false;
205
+ try {
206
+ ok = await ctx.ui.confirm(
207
+ 'Allow access outside the project?',
208
+ `The agent wants the delegated harness to access directories outside ${ctx.cwd}:\n\n${outside.map(d => ` ${d}`).join('\n')}\n\nOn non-readonly runs these may be writable.`,
209
+ );
210
+ } catch {
211
+ ok = false;
212
+ }
213
+ if (!ok) throw new Error(`addDirs outside the working directory (${list}) were declined by the user`);
214
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-harness-delegate",
3
- "version": "0.6.2",
4
- "description": "Delegate work to any harness (Claude Code, Muse, OpenCode, Amp) from the pi coding agent \u2014 code reviews, plans, implementation, security audits, docs, or your own custom templates.",
3
+ "version": "0.7.1",
4
+ "description": "Delegate work to any harness (Claude Code, Muse, OpenCode, Amp, Devin) from the pi coding agent \u2014 code reviews, plans, implementation, security audits, docs, or your own custom templates.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.14",
7
7
  "engines": {
@@ -21,6 +21,8 @@
21
21
  "codex",
22
22
  "opencode",
23
23
  "amp",
24
+ "devin",
25
+ "acp",
24
26
  "delegate",
25
27
  "subagent",
26
28
  "code-review",
@@ -1,61 +0,0 @@
1
- /** @deprecated use extensions/runner.ts + harnesses/claude.ts directly */
2
-
3
- import { claudeHarness } from './harnesses/claude.ts';
4
- import type { ActivityEvent, StreamedResult } from './harnesses/types.ts';
5
- import { runHarness } from './runner.ts';
6
-
7
- export interface RunClaudeOptions {
8
- prompt: string;
9
- cwd: string;
10
- permissionMode: string;
11
- model?: string;
12
- maxBudgetUsd?: number;
13
- addDirs?: string[];
14
- signal?: AbortSignal;
15
- timeoutMs?: number;
16
- resumeSessionId?: string;
17
- onStream?: (text: string) => void;
18
- onActivity?: (ev: ActivityEvent) => void;
19
- }
20
-
21
- export interface ClaudeResult extends StreamedResult {
22
- streamedText: string;
23
- }
24
-
25
- export const DEFAULT_TIMEOUT_MS = 600_000;
26
-
27
- function mapPerm(mode: string): 'readonly' | 'edit' | 'danger' {
28
- if (mode === 'plan') return 'readonly';
29
- if (mode === 'bypassPermissions') return 'danger';
30
- return 'edit';
31
- }
32
-
33
- export function runClaude(opts: RunClaudeOptions): Promise<ClaudeResult> {
34
- const perm = mapPerm(opts.permissionMode);
35
- const _nativePerm =
36
- perm === 'readonly' && opts.permissionMode !== 'plan'
37
- ? opts.permissionMode
38
- : perm === 'danger' && opts.permissionMode !== 'bypassPermissions'
39
- ? opts.permissionMode
40
- : undefined;
41
- // normalize: if caller passed a non-standard mode, preserve as native
42
- const isStandard =
43
- opts.permissionMode === 'plan' ||
44
- opts.permissionMode === 'acceptEdits' ||
45
- opts.permissionMode === 'bypassPermissions';
46
- return runHarness({
47
- harness: claudeHarness,
48
- prompt: opts.prompt,
49
- cwd: opts.cwd,
50
- permission: perm,
51
- nativePermission: isStandard ? undefined : opts.permissionMode,
52
- model: opts.model,
53
- maxBudgetUsd: opts.maxBudgetUsd,
54
- addDirs: opts.addDirs,
55
- signal: opts.signal,
56
- timeoutMs: opts.timeoutMs,
57
- resumeSessionId: opts.resumeSessionId,
58
- onStream: opts.onStream,
59
- onActivity: opts.onActivity,
60
- }).then(r => ({ ...r, streamedText: r.streamedText }));
61
- }
@@ -1,37 +0,0 @@
1
- /** @deprecated use extensions/harnesses/claude.ts parse directly */
2
- export type {
3
- ActivityEvent,
4
- ParseOutcome,
5
- ParseState,
6
- StreamedResult,
7
- StreamedUsage,
8
- StreamParseOutcome,
9
- } from './harnesses/types.ts';
10
-
11
- import { parseClaudeLine } from './harnesses/claude.ts';
12
- import type { ParseState, StreamParseOutcome } from './harnesses/types.ts';
13
-
14
- export function parseStreamLines(lines: Iterable<string>): StreamParseOutcome {
15
- const state: ParseState = { streamedText: '', activities: [], result: null };
16
- let streamedText = '';
17
- const activities: StreamParseOutcome['activities'] = [];
18
- let result: StreamParseOutcome['result'] = null;
19
- for (const line of lines) {
20
- const out = parseClaudeLine(line, state);
21
- if (out.streamedText) {
22
- streamedText += out.streamedText;
23
- state.streamedText += out.streamedText;
24
- }
25
- if (out.activities) {
26
- for (const a of out.activities) {
27
- activities.push(a);
28
- state.activities.push(a);
29
- }
30
- }
31
- if (out.result) {
32
- result = out.result;
33
- state.result = out.result;
34
- }
35
- }
36
- return { streamedText, result, activities };
37
- }