pi-harness-delegate 0.7.2 → 0.7.3
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 +10 -10
- package/extensions/acp-runner.ts +7 -1
- package/extensions/activity.ts +3 -0
- package/extensions/command.ts +14 -4
- package/extensions/config.ts +13 -9
- package/extensions/engine.ts +37 -6
- package/extensions/harnesses/amp.ts +3 -0
- package/extensions/harnesses/claude.ts +3 -0
- package/extensions/harnesses/codex.ts +1 -0
- package/extensions/harnesses/devin.ts +3 -0
- package/extensions/harnesses/opencode.ts +3 -0
- package/extensions/harnesses/registry.ts +46 -5
- package/extensions/harnesses/types.ts +7 -0
- package/extensions/index.ts +12 -3
- package/extensions/templates.ts +198 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -76,7 +76,7 @@ The `delegate` tool takes: `harness`, `task`, `mode`, `scope` (`diff` = git diff
|
|
|
76
76
|
|
|
77
77
|
### Fan out to multiple harnesses
|
|
78
78
|
|
|
79
|
-
`harness` also accepts `all` or a comma-separated list — the same task runs on every harness **concurrently**, up to `maxConcurrent`, and comes back as one comparison report instead of one report per harness:
|
|
79
|
+
`harness` also accepts `all` or a comma-separated list — the same task runs on every harness **concurrently**, up to `maxConcurrent`, and comes back as one comparison report instead of one report per harness (on `/delegate` and the `delegate` tool alike, the spec is case-insensitive and empty elements are dropped, so `claude,` is a plain single `claude` run, not a one-harness fan-out; a spec with no harness in it at all, like `,`, is an error rather than the default harness):
|
|
80
80
|
|
|
81
81
|
```bash
|
|
82
82
|
/delegate all review the auth flow # every *detected* harness
|
|
@@ -150,7 +150,7 @@ All frontmatter keys (one `key: value` per line; only `name` is required):
|
|
|
150
150
|
| `name` | The mode name used to select it (`--mode=`/`mode`) |
|
|
151
151
|
| `description` | Shown by `/delegate list` |
|
|
152
152
|
| `permission` | `readonly` \| `edit` \| `danger` (default `edit`). Any other value is treated as a native permission string |
|
|
153
|
-
| `permissionMode` / `sandbox` | Legacy
|
|
153
|
+
| `permissionMode` / `sandbox` | Legacy tier keys, only read when `permission` is absent (see below) |
|
|
154
154
|
| `model` | Default model for this mode (call → template → harness → global) |
|
|
155
155
|
| `maxBudgetUsd` | Default per-run spend cap in USD (a call's `maxBudgetUsd`/`--budget` wins) |
|
|
156
156
|
| `skill` | Appends `Use the "<skill>" skill.` to the prompt |
|
|
@@ -160,22 +160,22 @@ All frontmatter keys (one `key: value` per line; only `name` is required):
|
|
|
160
160
|
| `addDirs` | Comma-separated extra directories the harness may access (merged with the call's `addDirs`/`--add-dir`) |
|
|
161
161
|
| `harness` | Informational: which harness a template targets (shown by `/delegate list`) |
|
|
162
162
|
|
|
163
|
-
**Native escape hatch:** if you need a harness-specific permission not covered by the normalized set, put the native value in `permission:` (e.g. `permission: ask` in a `devin/` template) — it is passed to that harness as-is. Legacy `permissionMode:`/`sandbox:` keys only map onto the normalized tiers (`plan` → `readonly
|
|
163
|
+
**Native escape hatch:** if you need a harness-specific permission not covered by the normalized set, put the native value in `permission:` (e.g. `permission: ask` in a `devin/` template) — it is passed to that harness as-is. Legacy `permissionMode:`/`sandbox:` keys only map onto the normalized tiers, and only when `permission:` is absent (`permission:` always wins; a legacy key that disagrees with it is ignored but flagged with a `⚠` warning). Both keys accept both vocabularies, case-, whitespace- and `_`/`-`-insensitively: Claude's `plan` and Codex's `read-only` → `readonly`; `acceptEdits`/`dontAsk`/`auto`/`manual` and `workspace-write` → `edit`; `bypassPermissions` and `danger-full-access` → `danger` (gated behind `allowDangerous` / `--allow-dangerous` like any danger template). The three permission key names (`permission`, `permissionMode`, `sandbox`) are themselves case-insensitive (`Sandbox: read-only` counts); if a key appears more than once, or both legacy keys are set, the least permissive tier wins. An **unrecognized** legacy value fails closed: the template still loads, but as `readonly` (so it cannot write, and any `verify:` is skipped), with a `⚠ unrecognized sandbox: "…"` warning prefixed to its description in `/delegate list` and repeated on every run (a notification, a `- warning:` transcript line, and a prefix on the returned result). It is not dropped, because a dropped override would let a same-named builtin (e.g. `implement`, which is `edit`) run in its place, and that can be wider than what the author meant.
|
|
164
164
|
|
|
165
|
-
Native values are checked against a per-harness **allowlist** of readonly/edit-equivalent modes; anything else — the harness's own danger mode *or a value not on the list* — is treated as `danger` and needs `allowDangerous` / `--allow-dangerous` (fail closed). Once confirmed, an unlisted value still runs as declared rather than being swapped for the harness's danger mode.
|
|
165
|
+
Native values are checked against a per-harness **allowlist** of readonly/edit-equivalent modes; anything else — the harness's own danger mode *or a value not on the list* — is treated as `danger` and needs `allowDangerous` / `--allow-dangerous` (fail closed). Once confirmed, an unlisted value still runs as declared rather than being swapped for the harness's danger mode. Matching is case-insensitive (`Plan` is claude's `plan`), and an allowlisted value always reaches the harness in its canonical spelling (e.g. `acceptEdits`); a case variant of a danger mode (`Yolo`, `BYPASSPERMISSIONS`) is still danger. A native value that is genuinely read-only on its harness (**bold** below) runs as the `readonly` tier: it is recorded as `readonly` and a `verify:` command on it is skipped, exactly like `permission: readonly`.
|
|
166
166
|
|
|
167
167
|
| Harness | Native values treated as non-danger |
|
|
168
168
|
| --- | --- |
|
|
169
|
-
| `claude` |
|
|
170
|
-
| `codex` |
|
|
171
|
-
| `opencode` |
|
|
172
|
-
| `amp`/`omp` | `always-ask`, `write` |
|
|
173
|
-
| `devin` |
|
|
169
|
+
| `claude` | **`plan`**, `acceptEdits`, `manual`, `default` (`auto`, `dontAsk`, `bypassPermissions` → danger) |
|
|
170
|
+
| `codex` | **`read-only`**, `workspace-write` |
|
|
171
|
+
| `opencode` | **`plan`**, `build` (custom agents, `build --auto` → danger) |
|
|
172
|
+
| `amp`/`omp` | `always-ask`, `write` (`always-ask` stays `edit`: what `-p` does with an unanswerable ask is undocumented) |
|
|
173
|
+
| `devin` | **`plan`**, `accept-edits`, `ask` (`smart`, `bypass` → danger) |
|
|
174
174
|
|
|
175
175
|
**Verify:** `verify` is a shell command run **on the host** (never handed to the harness) right after it exits — e.g. `verify: bun test` on an `implement`/`docs`/`general` template turns "the harness says it's done" into an actual pass/fail. It's report-only: a failing verify is appended as its own section in the transcript and injected report, and surfaced in the tool result's `details.verify` (`{command, exitCode, ok}`), but it never changes whether the run itself is reported as an error — that stays whatever the harness reported. No template ships one by default — there's no universally-correct check command, so nothing is invented for you.
|
|
176
176
|
|
|
177
177
|
- **Sources, deliberately limited:** a verify command can only come from a template's `verify:` frontmatter, or a human typing `/delegate --verify="<cmd>"` (quotes needed for multi-word commands) — the call-level value wins over the template's. **It is not a parameter on the `delegate` tool** — that's on purpose, not an oversight: a tool param is set by the model, and the model's context includes repo content and delegated-harness output, both of which an attacker could influence, so a model-settable verify command would be a prompt-injection → arbitrary-host-command path. A model that wants verification simply picks a template that declares one.
|
|
178
|
-
- **Never runs on a `readonly` template
|
|
178
|
+
- **Never runs on a `readonly` template** — including a native read-only one (`permission: plan`, codex `read-only`; see the table above). `readonly` (`review`/`plan`/`security-audit`) guarantees no execution or modification — a verify command riding along on one would quietly break that guarantee. If a `readonly` template (or override) has a `verify` configured, it's recorded as skipped (`### Verify: \`cmd\`` / `⊘ skipped (readonly run)`) rather than run, and never silently dropped.
|
|
179
179
|
- A project-local template's `verify` command is gated by the same project-trust check as the rest of the template.
|
|
180
180
|
|
|
181
181
|
**Template sources (later wins):**
|
package/extensions/acp-runner.ts
CHANGED
|
@@ -128,6 +128,9 @@ interface PendingRequest {
|
|
|
128
128
|
export interface AcpRunnerInternals {
|
|
129
129
|
/** Overrides `HANDSHAKE_TIMEOUT_MS` so a hung-handshake test doesn't take 30s. */
|
|
130
130
|
handshakeTimeoutMs?: number;
|
|
131
|
+
/** Replaces `child_process.spawn`, so a test can inject a stdin 'error' (EPIPE) deterministically —
|
|
132
|
+
* bun never emits one for a real closed pipe, which left the stdin error guard untestable. */
|
|
133
|
+
spawn?: typeof spawn;
|
|
131
134
|
}
|
|
132
135
|
|
|
133
136
|
export function runAcpHarness(opts: RunHarnessOptions, internals: AcpRunnerInternals = {}): Promise<HarnessResult> {
|
|
@@ -150,7 +153,10 @@ export function runAcpHarness(opts: RunHarnessOptions, internals: AcpRunnerInter
|
|
|
150
153
|
resumeSessionId: opts.resumeSessionId,
|
|
151
154
|
});
|
|
152
155
|
|
|
153
|
-
const proc = spawn(opts.harness.binary, args, {
|
|
156
|
+
const proc = (internals.spawn ?? spawn)(opts.harness.binary, args, {
|
|
157
|
+
cwd: opts.cwd,
|
|
158
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
159
|
+
});
|
|
154
160
|
// A write to a child that already exited surfaces as an async EPIPE 'error' on stdin — not as a
|
|
155
161
|
// throw from write() (writeLine's try/catch can't see it). Unhandled, that event would crash the
|
|
156
162
|
// host process; the run itself already ends through the 'close'/timeout/abort paths.
|
package/extensions/activity.ts
CHANGED
|
@@ -461,6 +461,8 @@ export function buildTranscript(
|
|
|
461
461
|
verify?: VerifyResult;
|
|
462
462
|
/** Budget outcome, when `maxBudgetUsd` was set for this run. */
|
|
463
463
|
budget?: BudgetNote;
|
|
464
|
+
/** A template permission warning (e.g. an unrecognized legacy value failed closed to readonly). */
|
|
465
|
+
warning?: string;
|
|
464
466
|
} & Record<string, unknown>,
|
|
465
467
|
): string {
|
|
466
468
|
const harness = (opts.harness as string | undefined) ?? 'claude';
|
|
@@ -514,6 +516,7 @@ export function buildTranscript(
|
|
|
514
516
|
`- context: ${context ?? 'n/a'}`,
|
|
515
517
|
`- duration: ${duration ?? 'n/a'}`,
|
|
516
518
|
`- stop reason: ${opts.stopReason ?? 'n/a'}`,
|
|
519
|
+
...(opts.warning ? [`- warning: ${opts.warning}`] : []),
|
|
517
520
|
...(opts.budget ? [formatBudgetLine(opts.budget)] : []),
|
|
518
521
|
...(opts.budget?.message ? ['', opts.budget.message] : []),
|
|
519
522
|
'',
|
package/extensions/command.ts
CHANGED
|
@@ -68,11 +68,12 @@ function looksLikeHarnessSpec(word: string, knownHarnesses: ReadonlySet<string>)
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
|
-
* Normalize a harness spec the same way whether it came from `--harness
|
|
72
|
-
* lowercased, empty list elements dropped (`claude,` -> `claude`,
|
|
73
|
-
* alias-normalized (`omp` -> `amp`). A real list / `all` is left for
|
|
71
|
+
* Normalize a harness spec the same way whether it came from `--harness=`, the first word, or the
|
|
72
|
+
* `delegate` tool's `harness` param: lowercased, empty list elements dropped (`claude,` -> `claude`,
|
|
73
|
+
* `,` -> none), and a single name alias-normalized (`omp` -> `amp`). A real list / `all` is left for
|
|
74
|
+
* `resolveHarnessList`. Sharing it is what keeps both paths agreeing on single run vs fan-out.
|
|
74
75
|
*/
|
|
75
|
-
function normalizeHarnessSpec(spec: string): string | undefined {
|
|
76
|
+
export function normalizeHarnessSpec(spec: string): string | undefined {
|
|
76
77
|
const parts = spec
|
|
77
78
|
.toLowerCase()
|
|
78
79
|
.split(',')
|
|
@@ -83,6 +84,12 @@ function normalizeHarnessSpec(spec: string): string | undefined {
|
|
|
83
84
|
return parts.join(',');
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
/** The error for a harness spec that normalizes to nothing (`,`, `" , "`) — shared by `/delegate`'s
|
|
88
|
+
* `--harness=` and the `delegate` tool's `harness` param, so neither silently runs the default. */
|
|
89
|
+
export function emptyHarnessSpecError(raw: string): string {
|
|
90
|
+
return `harness ${JSON.stringify(raw)} names no harness: give a harness name, a comma-separated list, or "all" (omit it for the default harness)`;
|
|
91
|
+
}
|
|
92
|
+
|
|
86
93
|
/**
|
|
87
94
|
* One pass over the raw command: a backticked or double-quoted prose span is skipped verbatim (so
|
|
88
95
|
* `explain "--mode=x"` or `` `--allow-dangerous` `` in the prompt is never eaten as a flag); a
|
|
@@ -188,6 +195,9 @@ export function parseDelegateCommand(
|
|
|
188
195
|
}
|
|
189
196
|
|
|
190
197
|
let harness = flags.harness !== undefined ? normalizeHarnessSpec(flags.harness) : undefined;
|
|
198
|
+
// `--harness=,` / `--harness=" , "` / `--harness=` name no harness at all: an explicit choice we
|
|
199
|
+
// can't honor is an error, never a silent fall-back to the default harness.
|
|
200
|
+
if (flags.harness !== undefined && harness === undefined) errors.push(emptyHarnessSpecError(flags.harness));
|
|
191
201
|
let mode = flags.mode;
|
|
192
202
|
let task = rest.trim();
|
|
193
203
|
|
package/extensions/config.ts
CHANGED
|
@@ -145,21 +145,25 @@ export function parseMaxConcurrent(raw: unknown): DelegateConfig['maxConcurrent'
|
|
|
145
145
|
* that changes a default (maxConcurrent 1 -> 4 already did) would never reach that user.
|
|
146
146
|
*/
|
|
147
147
|
export function nonDefaultConfig(cfg: DelegateConfig): Partial<DelegateConfig> {
|
|
148
|
-
const defaults = defaultDelegateConfig()
|
|
149
|
-
const out:
|
|
150
|
-
|
|
151
|
-
|
|
148
|
+
const defaults = defaultDelegateConfig();
|
|
149
|
+
const out: Partial<DelegateConfig> = {};
|
|
150
|
+
// Typed per key so each value is copied into `out` under its own field type — no casts.
|
|
151
|
+
const keep = <K extends keyof DelegateConfig>(k: K): void => {
|
|
152
|
+
const v = cfg[k];
|
|
153
|
+
if (v !== undefined && JSON.stringify(v) !== JSON.stringify(defaults[k])) out[k] = v;
|
|
154
|
+
};
|
|
155
|
+
for (const k of Object.keys(cfg) as (keyof DelegateConfig)[]) {
|
|
152
156
|
if (k === 'modelAliases') {
|
|
153
157
|
// The loader merges aliases additively onto the defaults, so only the keys that differ need
|
|
154
158
|
// writing — whole-object diffing would pin the untouched default aliases next to a custom one.
|
|
155
|
-
const d = defaults.modelAliases
|
|
156
|
-
const diff = Object.fromEntries(Object.entries(
|
|
157
|
-
if (Object.keys(diff).length > 0) out
|
|
159
|
+
const d = defaults.modelAliases;
|
|
160
|
+
const diff = Object.fromEntries(Object.entries(cfg.modelAliases ?? {}).filter(([a, t]) => d[a] !== t));
|
|
161
|
+
if (Object.keys(diff).length > 0) out.modelAliases = diff;
|
|
158
162
|
continue;
|
|
159
163
|
}
|
|
160
|
-
|
|
164
|
+
keep(k);
|
|
161
165
|
}
|
|
162
|
-
return out
|
|
166
|
+
return out;
|
|
163
167
|
}
|
|
164
168
|
|
|
165
169
|
/**
|
package/extensions/engine.ts
CHANGED
|
@@ -32,7 +32,14 @@ import {
|
|
|
32
32
|
resolveModelForHarness,
|
|
33
33
|
resolveTransport,
|
|
34
34
|
} from './config.ts';
|
|
35
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
ALIASES,
|
|
37
|
+
canonicalSafeNativePermission,
|
|
38
|
+
classifyNativePermission,
|
|
39
|
+
getHarness,
|
|
40
|
+
HARNESS_NAMES,
|
|
41
|
+
nativePermissionTier,
|
|
42
|
+
} from './harnesses/registry.ts';
|
|
36
43
|
import type { ActivityEvent, NormalizedPermission } from './harnesses/types.ts';
|
|
37
44
|
import { runHarness } from './runner.ts';
|
|
38
45
|
import {
|
|
@@ -344,6 +351,13 @@ export async function delegate(
|
|
|
344
351
|
);
|
|
345
352
|
const task = opts.task || template.defaultTask;
|
|
346
353
|
if (!task) throw new Error(`delegate mode "${mode}" requires a task`);
|
|
354
|
+
// A template permission problem (an unrecognized legacy value failed closed to readonly, or an
|
|
355
|
+
// ignored legacy key) is otherwise only visible in `/delegate list` — say so where the run happens.
|
|
356
|
+
const warning = template.permissionWarning && `⚠ template "${mode}": ${template.permissionWarning}`;
|
|
357
|
+
if (warning) {
|
|
358
|
+
if (ctx.hasUI) ctx.ui.notify?.(warning, 'warning');
|
|
359
|
+
else process.stderr.write(`${warning}\n`);
|
|
360
|
+
}
|
|
347
361
|
|
|
348
362
|
// Fail-fast, before acquireSlot()/spawn — configuring e.g. transport:'acp' for a harness with no
|
|
349
363
|
// ACP surface (or 'stdout' for an ACP-only one) should error immediately with a clear message,
|
|
@@ -354,9 +368,23 @@ export async function delegate(
|
|
|
354
368
|
// human-confirmed in execute(); command: --allow-dangerous, human-confirmed in the handler). Resolved (and
|
|
355
369
|
// the danger refusal thrown) before acquireSlot() — it's pure, so a refused run never occupies
|
|
356
370
|
// (or, for fan-out, waits for) a concurrency slot it can't use.
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
371
|
+
const nativeClass = classifyNativePermission(harness, template.nativePermission);
|
|
372
|
+
// The tier the template actually runs at: a native read-only value (`permission: plan`) is filed
|
|
373
|
+
// under `edit` by normalizePermission, but runs as `readonly` — recorded as such and, above all,
|
|
374
|
+
// subject to resolveVerifyPlan's readonly skip. Only ever narrows (never `danger`), and the
|
|
375
|
+
// canonical native value below is still what reaches argv/ACP.
|
|
376
|
+
const templateTier: NormalizedPermission =
|
|
377
|
+
nativeClass === 'safe'
|
|
378
|
+
? (nativePermissionTier(harness, template.nativePermission) ?? template.permission)
|
|
379
|
+
: template.permission;
|
|
380
|
+
let permission: NormalizedPermission = templateTier;
|
|
381
|
+
// A safe native matches its allowlist case-insensitively (`Plan`), but what reaches argv/ACP is
|
|
382
|
+
// always the allowlist's canonical spelling (`plan`, claude's camelCase `acceptEdits`) — never the
|
|
383
|
+
// template's. Danger/unlisted values keep the template's own spelling (see registry.ts).
|
|
384
|
+
const nativePerm =
|
|
385
|
+
nativeClass === 'safe'
|
|
386
|
+
? canonicalSafeNativePermission(harness, template.nativePermission)
|
|
387
|
+
: template.nativePermission;
|
|
360
388
|
const isNativeDanger = nativeClass === 'danger' || nativeClass === 'unlisted';
|
|
361
389
|
if (template.permission === 'danger' || isNativeDanger) {
|
|
362
390
|
if (opts.allowDangerous !== true) {
|
|
@@ -381,7 +409,7 @@ export async function delegate(
|
|
|
381
409
|
const nativePermissionForRun =
|
|
382
410
|
nativeClass === 'unlisted' && permission === 'danger'
|
|
383
411
|
? nativePerm
|
|
384
|
-
: resolveNativePermission(
|
|
412
|
+
: resolveNativePermission(templateTier, permission, nativePerm);
|
|
385
413
|
|
|
386
414
|
const model = resolveModelForHarness(config, harnessName, opts.model, template.model);
|
|
387
415
|
const addDirs = mergeAddDirs(ctx.cwd, template.addDirs, opts.addDirs);
|
|
@@ -480,6 +508,7 @@ export async function delegate(
|
|
|
480
508
|
contextWindow: null,
|
|
481
509
|
activityLog: collectActivityLog(activityEvents),
|
|
482
510
|
output: streamedFull,
|
|
511
|
+
warning,
|
|
483
512
|
}),
|
|
484
513
|
);
|
|
485
514
|
} catch (_e) {
|
|
@@ -548,6 +577,7 @@ export async function delegate(
|
|
|
548
577
|
output: result.result || result.streamedText,
|
|
549
578
|
verify,
|
|
550
579
|
budget,
|
|
580
|
+
warning,
|
|
551
581
|
}),
|
|
552
582
|
);
|
|
553
583
|
pruneOutputs(outputsDirFor(harnessName), config.maxTranscripts);
|
|
@@ -556,7 +586,7 @@ export async function delegate(
|
|
|
556
586
|
|
|
557
587
|
const output = result.result || result.streamedText || '(empty result)';
|
|
558
588
|
return {
|
|
559
|
-
content: budget?.message
|
|
589
|
+
content: [warning, budget?.message, output].filter(Boolean).join('\n\n'),
|
|
560
590
|
details: {
|
|
561
591
|
harness: harnessName,
|
|
562
592
|
mode,
|
|
@@ -580,6 +610,7 @@ export async function delegate(
|
|
|
580
610
|
usage: result.usage,
|
|
581
611
|
verify,
|
|
582
612
|
budget,
|
|
613
|
+
permissionWarning: template.permissionWarning ?? null,
|
|
583
614
|
},
|
|
584
615
|
result,
|
|
585
616
|
activityLog: collectActivityLog(activityEvents),
|
|
@@ -273,6 +273,9 @@ export const ampHarness: Harness = {
|
|
|
273
273
|
permissionMap: { readonly: ['always-ask'], edit: ['write'], danger: ['yolo'] },
|
|
274
274
|
// `omp --approval-mode` values: always-ask, write, yolo.
|
|
275
275
|
safeNativePermissions: ['always-ask', 'write'],
|
|
276
|
+
// No `readonlyNativePermissions`: `omp --help` only says `always-ask` asks before tool calls — what a
|
|
277
|
+
// non-interactive `-p` run does with an unanswerable ask (deny, hang, or proceed) is undocumented and
|
|
278
|
+
// unverified, so a native `always-ask` stays `edit` (and its `verify:` still runs) until it is.
|
|
276
279
|
// `omp acp` is real and live-verified (docs/acp-harness-assessment.md §2/§4) — but deliberately
|
|
277
280
|
// NOT offered as a config value yet: its ACP mode surface only has 2 tiers (`default`/`plan`),
|
|
278
281
|
// while the stdout `--approval-mode` above has 3 genuine ones. Adding 'acp' here would let a
|
|
@@ -161,6 +161,9 @@ export const claudeHarness: Harness = {
|
|
|
161
161
|
// `auto` (a classifier auto-approves actions), and `dontAsk` — its effective reach is whatever the
|
|
162
162
|
// user's allow rules grant, which this extension can't see, so it fails closed.
|
|
163
163
|
safeNativePermissions: ['plan', 'acceptEdits', 'manual', 'default'],
|
|
164
|
+
// `plan` is claude's read-only mode. `manual`/`default` are not listed: under `-p` they deny what
|
|
165
|
+
// would need approval, but that is a consequence of the user's allow rules, not a no-write mode.
|
|
166
|
+
readonlyNativePermissions: ['plan'],
|
|
164
167
|
// `--max-budget-usd` (see buildArgs) — claude enforces the cap itself.
|
|
165
168
|
nativeBudget: true,
|
|
166
169
|
// No `acp` subcommand exists (docs/acp-harness-assessment.md §2) — confirmed against the full
|
|
@@ -293,6 +293,7 @@ export const codexHarness: Harness = {
|
|
|
293
293
|
permissionMap: { readonly: ['read-only'], edit: ['workspace-write'], danger: ['danger-full-access'] },
|
|
294
294
|
// `codex exec --sandbox` values: read-only, workspace-write, danger-full-access.
|
|
295
295
|
safeNativePermissions: ['read-only', 'workspace-write'],
|
|
296
|
+
readonlyNativePermissions: ['read-only'],
|
|
296
297
|
// No `acp` subcommand exists; `app-server` is a different, codex-proprietary JSON-RPC protocol,
|
|
297
298
|
// not ACP (docs/acp-harness-assessment.md §2) — confirmed against the full `--help` output of
|
|
298
299
|
// `codex`, `codex mcp-server`, `codex app-server`, and `codex exec`.
|
|
@@ -208,6 +208,9 @@ export const devinHarness: Harness = {
|
|
|
208
208
|
// action, which the ACP runner always declines. Not listed (=> danger): `bypass`, and `smart`
|
|
209
209
|
// ("additionally auto-runs actions a fast model judges safe" per `devin --help`).
|
|
210
210
|
safeNativePermissions: ['plan', 'accept-edits', 'ask'],
|
|
211
|
+
// `ask` is not listed: it is read-only here only because the ACP runner declines every permission
|
|
212
|
+
// request — a property of this client, not of the mode.
|
|
213
|
+
readonlyNativePermissions: ['plan'],
|
|
211
214
|
// ACP-only — no stdout mode exists to select between, so there's nothing to configure.
|
|
212
215
|
supportsTransports: ['acp'],
|
|
213
216
|
};
|
|
@@ -374,6 +374,9 @@ export const opencodeHarness: Harness = {
|
|
|
374
374
|
// `--agent <name>` (stdout) / ACP mode id: only the built-in `plan`/`build` agents are known
|
|
375
375
|
// quantities — a user-defined agent can carry any permission config, so it fails closed.
|
|
376
376
|
safeNativePermissions: ['plan', 'build'],
|
|
377
|
+
// the built-in `plan` agent (stdout) / ACP mode is read-only — live-captured blocking a write
|
|
378
|
+
// (docs/acp-harness-assessment.md §4).
|
|
379
|
+
readonlyNativePermissions: ['plan'],
|
|
377
380
|
// ACP path — opt-in only (transport defaults to 'stdout'; see config.ts's resolveTransport).
|
|
378
381
|
// `--model` isn't wired here: `opencode acp --help` has no such flag, unlike `devin acp
|
|
379
382
|
// --model`; the ACP handshake's own `configOptions` "model" category is the real mechanism
|
|
@@ -3,7 +3,7 @@ import { claudeHarness } from './claude.ts';
|
|
|
3
3
|
import { codexHarness } from './codex.ts';
|
|
4
4
|
import { devinHarness } from './devin.ts';
|
|
5
5
|
import { opencodeHarness } from './opencode.ts';
|
|
6
|
-
import type { Harness } from './types.ts';
|
|
6
|
+
import type { Harness, NormalizedPermission } from './types.ts';
|
|
7
7
|
|
|
8
8
|
export const HARNESSES: Record<string, Harness> = {
|
|
9
9
|
claude: claudeHarness,
|
|
@@ -54,8 +54,9 @@ export const normalizeHarnessName = resolveHarnessName;
|
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
56
|
* Legacy danger spellings, kept for templates written before harnesses were partitioned.
|
|
57
|
+
* Lowercase: native permissions are compared case-insensitively (see `classifyNativePermission`).
|
|
57
58
|
*/
|
|
58
|
-
const LEGACY_DANGER_TOKENS = new Set(['
|
|
59
|
+
const LEGACY_DANGER_TOKENS = new Set(['bypasspermissions', 'danger-full-access', 'danger']);
|
|
59
60
|
|
|
60
61
|
/**
|
|
61
62
|
* How a template's native permission string (`permission: <native>` escape hatch) classifies on
|
|
@@ -66,6 +67,13 @@ const LEGACY_DANGER_TOKENS = new Set(['bypassPermissions', 'danger-full-access',
|
|
|
66
67
|
* spelling: running it is exactly the harness's normalized danger tier.
|
|
67
68
|
* - `unlisted` — anything else. Treated as danger (fail closed): a permissive mode nobody listed
|
|
68
69
|
* (claude `auto`, devin `smart`, a custom opencode agent, …) must not slip past the gate.
|
|
70
|
+
*
|
|
71
|
+
* Matching is whitespace-trimmed and case-insensitive (`Plan` is claude's `plan`), and the danger
|
|
72
|
+
* checks run first, so a case variant of a danger token (`BYPASSPERMISSIONS`, `Yolo`) is still
|
|
73
|
+
* `danger`. A `safe` match is only a classification — the value that reaches argv/`session/set_mode`
|
|
74
|
+
* must be the allowlist's own spelling, via `canonicalSafeNativePermission` (claude's `acceptEdits`
|
|
75
|
+
* is camelCase; harness CLIs are case-sensitive, and a custom opencode agent named `PLAN` must
|
|
76
|
+
* never run in place of the built-in `plan` it was classified as).
|
|
69
77
|
*/
|
|
70
78
|
export type NativePermissionClass = 'none' | 'safe' | 'danger' | 'unlisted';
|
|
71
79
|
|
|
@@ -73,15 +81,48 @@ export function classifyNativePermission(
|
|
|
73
81
|
harness: Harness | undefined,
|
|
74
82
|
nativePermission: string | undefined,
|
|
75
83
|
): NativePermissionClass {
|
|
76
|
-
const native = nativePermission?.trim();
|
|
84
|
+
const native = nativePermission?.trim().toLowerCase();
|
|
77
85
|
if (!native) return 'none';
|
|
78
86
|
if (LEGACY_DANGER_TOKENS.has(native)) return 'danger';
|
|
79
87
|
const danger = harness?.permissionMap?.danger;
|
|
80
|
-
if (Array.isArray(danger) && danger.length > 0 && native === danger.join(' ')) return 'danger';
|
|
81
|
-
if (harness
|
|
88
|
+
if (Array.isArray(danger) && danger.length > 0 && native === danger.join(' ').toLowerCase()) return 'danger';
|
|
89
|
+
if (canonicalSafeNativePermission(harness, nativePermission) !== undefined) return 'safe';
|
|
82
90
|
return 'unlisted';
|
|
83
91
|
}
|
|
84
92
|
|
|
93
|
+
/**
|
|
94
|
+
* The harness's own allowlist spelling of a native permission that matches it case-insensitively
|
|
95
|
+
* (whitespace-trimmed), or `undefined` when it isn't on the allowlist. Only meaningful for a value
|
|
96
|
+
* `classifyNativePermission` calls `safe` — that's the one class whose value is ever rewritten.
|
|
97
|
+
*/
|
|
98
|
+
export function canonicalSafeNativePermission(
|
|
99
|
+
harness: Harness | undefined,
|
|
100
|
+
nativePermission: string | undefined,
|
|
101
|
+
): string | undefined {
|
|
102
|
+
const native = nativePermission?.trim().toLowerCase();
|
|
103
|
+
if (!native) return undefined;
|
|
104
|
+
return harness?.safeNativePermissions?.find(v => v.toLowerCase() === native);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The normalized tier a native escape-hatch value actually runs at, when it is narrower than the
|
|
109
|
+
* `edit` tier `normalizePermission` files every native value under: `readonly` for a value on the
|
|
110
|
+
* harness's `readonlyNativePermissions` (claude/devin/opencode `plan`, codex `read-only`), matched
|
|
111
|
+
* case-insensitively through the same allowlist as `canonicalSafeNativePermission`. `undefined`
|
|
112
|
+
* otherwise — the template's own tier stands. Only ever narrows: it never reports `danger`, so it
|
|
113
|
+
* can't touch the danger gate, and it changes nothing about what reaches argv/ACP (the canonical
|
|
114
|
+
* native value is still what the harness gets). What it does change is the recorded permission and
|
|
115
|
+
* `resolveVerifyPlan`'s readonly skip — a native read-only run must never execute a `verify:` command.
|
|
116
|
+
*/
|
|
117
|
+
export function nativePermissionTier(
|
|
118
|
+
harness: Harness | undefined,
|
|
119
|
+
nativePermission: string | undefined,
|
|
120
|
+
): NormalizedPermission | undefined {
|
|
121
|
+
const canonical = canonicalSafeNativePermission(harness, nativePermission);
|
|
122
|
+
if (canonical === undefined) return undefined;
|
|
123
|
+
return harness?.readonlyNativePermissions?.includes(canonical) ? 'readonly' : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
85
126
|
/**
|
|
86
127
|
* Does this native permission string require the danger gate on this harness?
|
|
87
128
|
*
|
|
@@ -96,6 +96,13 @@ export interface Harness {
|
|
|
96
96
|
* never heard of — is treated as danger (fail closed) by `isNativeDangerPermission` in registry.ts.
|
|
97
97
|
* Omitted -> no native value is considered safe. Covers both transports' vocabularies. */
|
|
98
98
|
safeNativePermissions?: readonly string[];
|
|
99
|
+
/** The subset of `safeNativePermissions` that is genuinely read-only (no writes, no command
|
|
100
|
+
* execution) on this harness. A native escape-hatch value listed here runs as the normalized
|
|
101
|
+
* `readonly` tier — recorded as `readonly` and, crucially, never lets a `verify:` command execute
|
|
102
|
+
* host-side (see `resolveVerifyPlan`). Conservative by design: only list a mode whose read-only
|
|
103
|
+
* behavior is documented for a non-interactive run; anything else stays `edit`. Must be spelled
|
|
104
|
+
* exactly as in `safeNativePermissions` (matching is case-insensitive via that allowlist). */
|
|
105
|
+
readonlyNativePermissions?: readonly string[];
|
|
99
106
|
/** True when `buildArgs` passes `maxBudgetUsd` to a native CLI budget flag (claude's
|
|
100
107
|
* `--max-budget-usd`). Otherwise the runners enforce it host-side from the streamed
|
|
101
108
|
* `totalCostUsd` — possible only when the harness actually reports cost. */
|
package/extensions/index.ts
CHANGED
|
@@ -27,7 +27,9 @@ import { formatToolUse, ToolCallIndex } from './activity.ts';
|
|
|
27
27
|
import {
|
|
28
28
|
aliasUsage,
|
|
29
29
|
delegateUsage,
|
|
30
|
+
emptyHarnessSpecError,
|
|
30
31
|
isFanoutSpec,
|
|
32
|
+
normalizeHarnessSpec,
|
|
31
33
|
parseDelegateCommand,
|
|
32
34
|
resolveDefaults,
|
|
33
35
|
resolveHarnessFilter,
|
|
@@ -144,8 +146,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
144
146
|
promptGuidelines: [...DELEGATE_TOOL_GUIDELINES],
|
|
145
147
|
parameters: DELEGATE_TOOL_PARAMS,
|
|
146
148
|
async execute(_toolCallId, rawParams, signal, onUpdate, ctx) {
|
|
147
|
-
// the deprecated alias pins its harness; everything below sees the effective params
|
|
148
|
-
|
|
149
|
+
// the deprecated alias pins its harness; everything below sees the effective params. The spec is
|
|
150
|
+
// normalized exactly as /delegate does (normalizeHarnessSpec), so `claude,` is a single run —
|
|
151
|
+
// fail-fast at capacity, single-run result shape — not a one-harness fan-out, and `omp` is `amp`.
|
|
152
|
+
// A non-empty spec that normalizes to nothing (`,`, `" , "`) is refused rather than silently run
|
|
153
|
+
// on the default harness (`""` stays "unset", the way a model omitting the field means it).
|
|
154
|
+
const harnessSpec = spec.forceHarness ?? rawParams.harness;
|
|
155
|
+
const harness = harnessSpec === undefined ? undefined : normalizeHarnessSpec(harnessSpec);
|
|
156
|
+
if (harnessSpec && harness === undefined) throw new Error(emptyHarnessSpecError(harnessSpec));
|
|
157
|
+
const params: DelegateToolParams = { ...rawParams, harness };
|
|
149
158
|
const config = loadConfig();
|
|
150
159
|
// A model-set allowDangerous is never honored on its own — a human confirms it (or, with no
|
|
151
160
|
// UI to ask, it's refused). Checked once up front, before any fan-out. See validate.ts.
|
|
@@ -192,7 +201,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
192
201
|
};
|
|
193
202
|
},
|
|
194
203
|
renderCall(params, theme) {
|
|
195
|
-
const harness = params.harness ?? 'delegate';
|
|
204
|
+
const harness = spec.forceHarness ?? params.harness ?? 'delegate';
|
|
196
205
|
const mode = params.mode ?? 'general';
|
|
197
206
|
const task = params.task ?? '';
|
|
198
207
|
const taskStr = task ? ` — ${task.length > 60 ? `${task.slice(0, 59)}…` : task}` : '';
|
package/extensions/templates.ts
CHANGED
|
@@ -23,6 +23,12 @@ export interface DelegateTemplate {
|
|
|
23
23
|
nativePermission?: string;
|
|
24
24
|
/** Legacy raw permissionMode for transcript compat. */
|
|
25
25
|
permissionMode: PermissionMode;
|
|
26
|
+
/**
|
|
27
|
+
* A problem with the template's permission keys, shown in `/delegate list` and at run time: an
|
|
28
|
+
* unrecognized legacy `permissionMode:`/`sandbox:` value (failed closed to `readonly`), or a
|
|
29
|
+
* legacy key that disagrees with `permission:` and was ignored (tier unchanged).
|
|
30
|
+
*/
|
|
31
|
+
permissionWarning?: string;
|
|
26
32
|
model?: string;
|
|
27
33
|
maxBudgetUsd?: number;
|
|
28
34
|
skill?: string;
|
|
@@ -36,35 +42,180 @@ export interface DelegateTemplate {
|
|
|
36
42
|
harness?: string;
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
/** Tier order, least permissive first — used to pick the safer of two conflicting legacy keys. */
|
|
46
|
+
const TIER_RANK: Record<NormalizedPermission, number> = { readonly: 0, edit: 1, danger: 2 };
|
|
47
|
+
|
|
48
|
+
const TIER_PERMISSION_MODE: Record<NormalizedPermission, PermissionMode> = {
|
|
49
|
+
readonly: 'plan',
|
|
50
|
+
edit: 'acceptEdits',
|
|
51
|
+
danger: 'bypassPermissions',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Claude `PermissionMode` names, matched case- and `-`/`_`-insensitively (`accept-edits` → `acceptEdits`). */
|
|
55
|
+
const PERMISSION_MODES_BY_LOWER = new Map<string, PermissionMode>([...PERMISSION_MODES].map(m => [m.toLowerCase(), m]));
|
|
56
|
+
|
|
57
|
+
/** Codex `--sandbox` values (the legacy `sandbox:` key) → normalized tier. Keys are lowercase, `_`→`-`. */
|
|
58
|
+
const LEGACY_SANDBOX_TIERS: Record<string, NormalizedPermission> = {
|
|
59
|
+
'read-only': 'readonly',
|
|
60
|
+
readonly: 'readonly',
|
|
61
|
+
'workspace-write': 'edit',
|
|
62
|
+
'danger-full-access': 'danger',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A frontmatter value echoed back in a warning (`/delegate list`, run-time notes): JSON-quoted so
|
|
67
|
+
* control characters / ANSI escapes are inert, and capped so a huge value can't flood the line.
|
|
68
|
+
*/
|
|
69
|
+
function quoteValue(value: string): string {
|
|
70
|
+
return JSON.stringify(value).slice(0, 60);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface LegacyPermission {
|
|
74
|
+
permission: NormalizedPermission;
|
|
75
|
+
permissionMode: PermissionMode;
|
|
76
|
+
/** Set when a legacy value was unrecognized and the template was failed closed to `readonly`. */
|
|
77
|
+
permissionWarning?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Map one legacy `permissionMode:`/`sandbox:` value onto a normalized tier. Both keys accept both
|
|
82
|
+
* vocabularies (claude PermissionMode names and codex sandbox values), case/whitespace/`-`/`_`-insensitive.
|
|
83
|
+
* Returns `null` for an unrecognized value — the caller fails that closed.
|
|
84
|
+
*/
|
|
85
|
+
function classifyLegacyValue(value: string): Omit<LegacyPermission, 'permissionWarning'> | null {
|
|
86
|
+
const lower = value.trim().toLowerCase();
|
|
87
|
+
// Claude names have no separators of their own, so `accept-edits`/`bypass_permissions` can only
|
|
88
|
+
// ever mean that same name (and tier) — danger stays gated behind allowDangerous downstream.
|
|
89
|
+
const mode = PERMISSION_MODES_BY_LOWER.get(lower.replace(/[-_]/g, ''));
|
|
90
|
+
if (mode) {
|
|
91
|
+
if (mode === 'plan') return { permission: 'readonly', permissionMode: mode };
|
|
92
|
+
if (mode === 'bypassPermissions') return { permission: 'danger', permissionMode: mode };
|
|
93
|
+
return { permission: 'edit', permissionMode: mode };
|
|
94
|
+
}
|
|
95
|
+
const tier = LEGACY_SANDBOX_TIERS[lower.replace(/_/g, '-')];
|
|
96
|
+
return tier ? { permission: tier, permissionMode: TIER_PERMISSION_MODE[tier] } : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The legacy keys actually set (non-empty), as `[key, value]` pairs. */
|
|
100
|
+
function legacyEntries(permissionMode: FrontmatterValues, sandbox: FrontmatterValues): [string, string][] {
|
|
101
|
+
return [
|
|
102
|
+
...values(permissionMode).map(v => ['permissionMode', v] as [string, string]),
|
|
103
|
+
...values(sandbox).map(v => ['sandbox', v] as [string, string]),
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A permission key's value(s): the keys are read case-insensitively and every occurrence is kept
|
|
109
|
+
* (`sandbox:` + `Sandbox:` is two values), so callers take an array; a single string still works.
|
|
110
|
+
*/
|
|
111
|
+
type FrontmatterValues = string | readonly string[] | undefined;
|
|
112
|
+
|
|
113
|
+
/** Non-empty trimmed values — an empty value means "not set". */
|
|
114
|
+
function values(v: FrontmatterValues): string[] {
|
|
115
|
+
return (typeof v === 'string' ? [v] : (v ?? [])).map(x => x.trim()).filter(Boolean);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Legacy `permissionMode:`/`sandbox:` keys → normalized tier. Only consulted when `permission:` is
|
|
120
|
+
* absent. An empty/absent value means "not set" (default `edit`, as always).
|
|
121
|
+
*
|
|
122
|
+
* **Fails closed:** an unrecognized value (a typo, a value from some other harness's vocabulary)
|
|
123
|
+
* yields `readonly` plus `permissionWarning` — never the old silent `edit`, and never a refusal
|
|
124
|
+
* to load. Not loading would let a same-named lower tier (e.g. the builtin `implement`, `edit`)
|
|
125
|
+
* silently win instead, which can be MORE permissive than an author who wrote `sandbox: readonyl`
|
|
126
|
+
* meant; `readonly` is the one tier that can never exceed what any author meant (and it also skips
|
|
127
|
+
* `verify:`). When both keys (or several case variants of one) are set, the least permissive wins.
|
|
128
|
+
*/
|
|
129
|
+
export function normalizeLegacyPermission(
|
|
130
|
+
permissionMode: FrontmatterValues,
|
|
131
|
+
sandbox: FrontmatterValues,
|
|
132
|
+
): LegacyPermission {
|
|
133
|
+
const set = legacyEntries(permissionMode, sandbox);
|
|
134
|
+
if (set.length === 0) return { permission: 'edit', permissionMode: 'acceptEdits' };
|
|
135
|
+
let best: Omit<LegacyPermission, 'permissionWarning'> | null = null;
|
|
136
|
+
for (const [key, value] of set) {
|
|
137
|
+
const c = classifyLegacyValue(value);
|
|
138
|
+
if (!c) {
|
|
139
|
+
return {
|
|
140
|
+
permission: 'readonly',
|
|
141
|
+
permissionMode: 'plan',
|
|
142
|
+
permissionWarning: `unrecognized ${key}: ${quoteValue(value)} — loaded as readonly (fail closed)`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (!best || TIER_RANK[c.permission] < TIER_RANK[best.permission]) best = c;
|
|
146
|
+
}
|
|
147
|
+
return best as LegacyPermission;
|
|
148
|
+
}
|
|
149
|
+
|
|
39
150
|
export function normalizePermission(
|
|
40
|
-
raw:
|
|
41
|
-
fallbackMode:
|
|
42
|
-
|
|
151
|
+
raw: FrontmatterValues,
|
|
152
|
+
fallbackMode: FrontmatterValues,
|
|
153
|
+
sandbox?: FrontmatterValues,
|
|
154
|
+
): {
|
|
155
|
+
permission: NormalizedPermission;
|
|
156
|
+
nativePermission?: string;
|
|
157
|
+
permissionMode: PermissionMode;
|
|
158
|
+
permissionWarning?: string;
|
|
159
|
+
} {
|
|
43
160
|
// Prefer normalized permission
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
161
|
+
const raws = values(raw);
|
|
162
|
+
if (raws.length > 1) {
|
|
163
|
+
// `permission:` given more than once (e.g. `permission:` and `Permission:`) — the same tier is
|
|
164
|
+
// fine; disagreeing tiers take the least permissive, and a disagreement involving a native value
|
|
165
|
+
// (whose tier is per-harness, so not comparable here) fails closed to readonly. Never "last wins".
|
|
166
|
+
const all = raws.map(normalizeTierValue);
|
|
167
|
+
const distinct = new Set(all.map(r => `${r.permission}|${r.nativePermission ?? ''}`));
|
|
168
|
+
if (distinct.size > 1) {
|
|
169
|
+
const listed = raws.map(quoteValue).join(', ');
|
|
170
|
+
if (all.some(r => r.nativePermission))
|
|
171
|
+
return {
|
|
172
|
+
permission: 'readonly',
|
|
173
|
+
permissionMode: 'plan',
|
|
174
|
+
permissionWarning: `conflicting permission: values ${listed} — loaded as readonly (fail closed)`,
|
|
175
|
+
};
|
|
176
|
+
const least = all.reduce((a, b) => (TIER_RANK[b.permission] < TIER_RANK[a.permission] ? b : a));
|
|
177
|
+
return {
|
|
178
|
+
...least,
|
|
179
|
+
permissionWarning: `conflicting permission: values ${listed} — using the least permissive, ${least.permission}`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
59
182
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
183
|
+
if (raws.length > 0) {
|
|
184
|
+
const resolved = normalizeTierValue(raws[0]);
|
|
185
|
+
// `permission:` always wins; a legacy key that disagrees is ignored, but said so — an author who
|
|
186
|
+
// wrote `sandbox: read-only` next to `permission: edit` should see which one ran. Skipped for a
|
|
187
|
+
// native value: its tier is only known per harness at run time, so "disagrees" isn't decidable here.
|
|
188
|
+
const legacy = legacyEntries(fallbackMode, sandbox);
|
|
189
|
+
if (resolved.nativePermission || legacy.length === 0) return resolved;
|
|
190
|
+
const l = normalizeLegacyPermission(fallbackMode, sandbox);
|
|
191
|
+
if (!l.permissionWarning && l.permission === resolved.permission) return resolved;
|
|
192
|
+
const ignored = legacy.map(([k, v]) => `${k}: ${quoteValue(v)}`).join(', ');
|
|
193
|
+
return { ...resolved, permissionWarning: `permission: ${resolved.permission} overrides ${ignored} (ignored)` };
|
|
66
194
|
}
|
|
67
|
-
|
|
195
|
+
// Legacy permissionMode/sandbox mapping — fails closed to readonly on an unrecognized value
|
|
196
|
+
return normalizeLegacyPermission(fallbackMode, sandbox);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** One `permission:` value → tier, or (unknown value) the native escape hatch. */
|
|
200
|
+
function normalizeTierValue(raw: string): {
|
|
201
|
+
permission: NormalizedPermission;
|
|
202
|
+
nativePermission?: string;
|
|
203
|
+
permissionMode: PermissionMode;
|
|
204
|
+
} {
|
|
205
|
+
const lower = raw.trim().toLowerCase();
|
|
206
|
+
if (lower === 'readonly' || lower === 'read-only' || lower === 'read_only')
|
|
207
|
+
return { permission: 'readonly', permissionMode: 'plan' };
|
|
208
|
+
if (lower === 'edit' || lower === 'acceptedits' || lower === 'accept-edits')
|
|
209
|
+
return { permission: 'edit', permissionMode: 'acceptEdits' };
|
|
210
|
+
if (
|
|
211
|
+
lower === 'danger' ||
|
|
212
|
+
lower === 'bypasspermissions' ||
|
|
213
|
+
lower === 'danger-full-access' ||
|
|
214
|
+
lower === 'danger_full_access'
|
|
215
|
+
)
|
|
216
|
+
return { permission: 'danger', permissionMode: 'bypassPermissions' };
|
|
217
|
+
// Unknown native — treat as native escape hatch
|
|
218
|
+
return { permission: 'edit', nativePermission: raw.trim(), permissionMode: 'acceptEdits' };
|
|
68
219
|
}
|
|
69
220
|
|
|
70
221
|
/** Comma-separated frontmatter list (`a, b`) — undefined when absent or empty. */
|
|
@@ -76,33 +227,50 @@ function parseList(raw: string | undefined): string[] | undefined {
|
|
|
76
227
|
return items.length > 0 ? items : undefined;
|
|
77
228
|
}
|
|
78
229
|
|
|
230
|
+
type PermissionKey = 'permission' | 'permissionMode' | 'sandbox';
|
|
231
|
+
const PERMISSION_KEYS = new Map<string, PermissionKey>([
|
|
232
|
+
['permission', 'permission'],
|
|
233
|
+
['permissionmode', 'permissionMode'],
|
|
234
|
+
['sandbox', 'sandbox'],
|
|
235
|
+
]);
|
|
236
|
+
|
|
79
237
|
/** Parse a template file: frontmatter (---\nkey: value\n---) + markdown body. */
|
|
80
238
|
export function parseTemplate(text: string): DelegateTemplate | null {
|
|
81
239
|
const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text.trimStart());
|
|
82
240
|
if (!m) return null;
|
|
83
241
|
|
|
84
242
|
const meta: Record<string, string> = {};
|
|
243
|
+
// The keys that decide the tier are matched case-insensitively and keep every occurrence: an
|
|
244
|
+
// exact-case lookup let `Sandbox: read-only` be silently ignored (→ the `edit` default, verify on).
|
|
245
|
+
const perm: Record<PermissionKey, string[]> = { permission: [], permissionMode: [], sandbox: [] };
|
|
85
246
|
for (const line of m[1].split('\n')) {
|
|
86
247
|
const i = line.indexOf(':');
|
|
87
248
|
if (i <= 0) continue;
|
|
88
|
-
|
|
249
|
+
const key = line.slice(0, i).trim();
|
|
250
|
+
const value = line.slice(i + 1).trim();
|
|
251
|
+
const permKey = PERMISSION_KEYS.get(key.toLowerCase());
|
|
252
|
+
if (permKey) perm[permKey].push(value);
|
|
253
|
+
else meta[key] = value;
|
|
89
254
|
}
|
|
90
255
|
|
|
91
256
|
const name = meta.name?.trim();
|
|
92
257
|
if (!name) return null;
|
|
93
258
|
|
|
94
|
-
const
|
|
95
|
-
const permModeRaw = meta.permissionMode?.trim() ?? meta.sandbox?.trim();
|
|
96
|
-
const norm = normalizePermission(permRaw, permModeRaw);
|
|
259
|
+
const norm = normalizePermission(perm.permission, perm.permissionMode, perm.sandbox);
|
|
97
260
|
|
|
98
261
|
const budget = meta.maxBudgetUsd ? Number(meta.maxBudgetUsd) : NaN;
|
|
262
|
+
const description = meta.description ?? '';
|
|
99
263
|
|
|
100
264
|
return {
|
|
101
265
|
name,
|
|
102
|
-
|
|
266
|
+
// A permission-key problem is surfaced where the template is listed (`/delegate list`).
|
|
267
|
+
description: norm.permissionWarning
|
|
268
|
+
? `⚠ ${norm.permissionWarning}${description ? ` · ${description}` : ''}`
|
|
269
|
+
: description,
|
|
103
270
|
permission: norm.permission,
|
|
104
271
|
nativePermission: norm.nativePermission,
|
|
105
272
|
permissionMode: norm.permissionMode,
|
|
273
|
+
permissionWarning: norm.permissionWarning,
|
|
106
274
|
model: meta.model || undefined,
|
|
107
275
|
maxBudgetUsd: Number.isFinite(budget) && budget > 0 ? budget : undefined,
|
|
108
276
|
skill: meta.skill || undefined,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-harness-delegate",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
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",
|