feat(harness)!: pi extensions under one plugins: key - #6754
Conversation
PR Summary by QodoAdd secure harness-managed extensions for the pi runtime
AI Description
Diagram
High-Level Assessment
Files changed (33)
|
|
🤖 Finished Review · ✅ Success · Started 4:42 PM UTC · Completed 5:01 PM UTC Commit: Runtime: claude · Model: opus → claude-opus-4-6 · Effort: high · Cost: $17.82 |
Site previewPreview: https://bae257f9-site.fullsend-ai.workers.dev Commit: |
Code Review by Qodo
1.
|
|
Risk Assessment: elevated (3/5) DetailsLarge feature addition (41 files, ~4486 lines). High blast radius (change size composite = 5) mitigated by no protected paths, no security-sensitive files, strong test coverage (32% test ratio), and 7 of 41 files being new/additive. Tier 2 highlights compose.go (24 commits/30d, 7 authors/90d, 32 fix/revert commits), harness.go (23 commits/30d, 10 authors/90d, 26 fix/revert commits), and run.go (85 commits/30d, 14 authors/90d, 189 fix/revert commits) as high-churn hotspots. Preserving prior score of 3 per anchoring rules: Tier 1 signals are unchanged from prior assessment, and Tier 2/3 confirm the same hotspot and feature-driven patterns without introducing new reasons to deviate. Previous runRisk Assessment: elevated (3/5) DetailsLarge feature addition (41 files, ~4484 lines). High blast radius (change size composite = 5) mitigated by no protected paths, no security-sensitive files, strong test coverage (32% test ratio), and predominantly additive new files. Tier 2 highlights compose.go as a high-churn hotspot (24 commits/30d, 7 authors/90d, 32 fix/revert commits) and harness.go (23 commits/30d, 10 authors/90d, 15 fix/revert commits). Preserving prior score of 3 per anchoring rules since Tier 1 signals are unchanged and Tier 2/3 provide no specific reason to deviate. Previous run (2)Risk Assessment: elevated (3/5) DetailsLarge feature addition (41 files, ~4394 lines). High blast radius (change size composite = 5) mitigated by no protected paths, no security-sensitive files, strong test coverage (32% test ratio), and predominantly additive new files. Tier 2 highlights compose.go as a high-churn hotspot (24 commits/30d, 7 authors/90d). Preserving prior score of 3 per anchoring rules since Tier 1 signals are unchanged. Previous run (3)Risk Assessment: elevated (3/5) DetailsLarge feature addition (40 files, ~3792 lines) introducing pi extension harness resources across runtime, harness, CLI, and documentation layers. Tier 1 signals are materially unchanged from prior assessment (files 35 to 40, lines 3034 to 3792, same scoring bands). High blast radius (change size composite = 5) mitigated by no protected paths, no security-sensitive files, strong test coverage (30% test ratio), and predominantly additive new files. Tier 2 highlights compose.go as a high-churn hotspot (24 commits/30d, 7 authors/90d). Preserving prior score of 3 per anchoring rules since Tier 1 signals are unchanged. Previous run (4)Risk Assessment: elevated (3/5) DetailsLarge feature addition (35 files, ~3034 lines) introducing pi extension harness resources across runtime, harness, CLI, and documentation layers. High blast radius (change size composite = 5) mitigated by strong test coverage (34% test ratio), no protected paths, no security-sensitive files, and additive-only changes with majority new files. Re-review anchoring preserves prior elevated score as Tier 1 signals are largely unchanged from prior assessment. Previous run (5)Risk Assessment: elevated (3/5) DetailsLarge feature addition (32 files, ~2477 lines) introducing pi extension harness resources across runtime, harness, CLI, and documentation layers. High blast radius mitigated by strong test coverage (36% test ratio), thorough ADR, and no protected paths. |
ReviewFindingsLow
Next steps:
Previous runReviewFindingsLow
Next steps:
Previous run (2)ReviewFindingsMedium
Low
Next steps:
Previous run (3)ReviewFindingsMedium
Low
Next steps:
Previous run (4)ReviewFindingsLow
Next steps:
Previous run (5)ReviewFindingsMedium
Low
Next steps:
|
18f0e1e to
0d0bfb8
Compare
|
🤖 Finished Review · ✅ Success · Started 6:11 PM UTC · Completed 6:30 PM UTC Commit: Runtime: claude · Model: opus → claude-opus-4-6 · Effort: high · Cost: $13.37 |
0d0bfb8 to
529410d
Compare
|
🤖 Finished Review · ✅ Success · Started 6:57 PM UTC · Completed 7:40 PM UTC Commit: Runtime: claude · Model: opus → claude-opus-4-6 · Effort: high |
529410d to
774cb97
Compare
|
🤖 Review · Commit: |
Wire the harness `extensions:` list (ADR 0094) through the runner into the pi runtime. BootstrapInput gains Extensions() []ExtensionInput and RunParams carries the same list; the runner's content scan covers every text file of each extension directory (node_modules included, binaries skipped by a NUL probe). PiRuntime.Bootstrap uploads each directory to /sandbox/pi-config/ extensions/<name>/ (a runner-owned path pi never auto-discovers), refuses name collisions with the hook adapter and the vendored provider extensions, and records name/path/tree-hash/args/env in the manifest. PiRuntime.Run re-hashes the host directories and renders a preflight that runs before the agent-writable .env is sourced: every extension must exist in the sandbox and hash to the host value, else the iteration stops with exit 96 before any extension code runs. The expected hash comes from the host, never from the manifest, which sits in the agent-writable config dir. The tree hash has one definition implemented in Go and as a POSIX sh pipeline (sha256sum-native lines, LC_ALL=C sorted); TestPiExtensionTreeHash_MatchesShell checks the two agree under sh and dash. Extensions are loaded with -e after the provider extension and the hook adapter — pi runs tool_call handlers in -e order and the first block wins — with their args verbatim, and their env is exported last so it cannot override the runtime's pins. --tools is unchanged. The hook adapter treats a name that is neither a pi built-in nor a Claude-vocabulary name as an extension tool when the manifest lists extensions: the tool_allowlist script is skipped for it only when the agent declared no tools: (pi's --tools already hides it otherwise), every other PreToolUse/PostToolUse group still runs, each new name is logged once, and the session_start roster names the extensions. Live progress for extension tools shows the first string argument among path/file/pattern/query/command instead of nothing; the two existing unknown-tool expectations in pi_progress_test.go change accordingly. ClaudeRuntime warns and skips declared extensions, mirroring pi's plugins: warning. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
Add ADR 0094 for the harness `extensions:` key: extensions are harness-repo content with the same trust as skills/plugins/scripts, they are local and vendored, and because --no-extensions plus explicit -e closes the set of code that can register tools, no per-tool declaration is needed. Rejected alternatives: reusing plugins:, pi's settings.json package sources, a mandatory tool manifest. Follow-ups (image-baked prefix form, replaces_builtin guards, per-tool Claude-name mapping, Track E, --tools union) are listed under Consequences. Document the feature: a new Extensions section in docs/runtimes/pi.md (YAML, trust story, run-time behaviour, tools: interaction, Claude Code skip, vendoring, troubleshooting incl. exit 96), the extensions row in the runtimes.md key-support matrix, the pi-internals bullet in runtime-implementation.md, the harness-reference cross-link, an architecture.md Decided line and the roadmap mention. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
…o contributing The pi runtime page documented `extensions:` as security design notes: a ~1,900-word section headed "The loader cache is off", "And the rest of the loader environment is cleared", with the full env deny-list, BOM handling, `!` disable-pattern semantics and jiti internals inline. A harness author who just wants to ship an extension could not follow it straight through. The harness reference had the same problem in one ~500-word paragraph. Split by audience rather than deleting anything: - `docs/runtimes/pi.md` § Extensions is now a walkthrough — what it is and the YAML, a checklist of what makes a valid extension directory, `args` and `env`, extension tools under `tools:`, what happens at run time, and a symptom/cause/fix table. 169 lines down to 100. - `docs/contributing/runtime-implementation.md` gains a "Pi extensions" subsection that absorbs the internals: how validation mirrors pi's loader (the `pi`-object precedence, package layout, containment, BOM, glob and `!` semantics, tree admissibility, scan limits), the upload and tree-hash preflight, why the loader environment is pinned (jiti cache, `JITI_ALIAS`, `unset` as a special builtin, the residual TOCTOU), and why `args`/`env` are validated so narrowly. The jiti prose in "Process and exit codes" and the declared-extension bullet in "Hook adapter contract" now point at it instead of restating it. - `docs/reference/harness-reference.md` states the `extensions` rules as a lead sentence plus one bullet per rule, and its YAML comment fits the column style of its neighbours. - ADR 0094 points run-time mechanics at the contributor section as well as the walkthrough. Corrections found while checking every quoted string against `internal/`: the Claude Code skip message ends with `(see docs/runtimes.md)`, and the top-level entry points are `index.js`/`.ts`/`.mjs`/`.cjs` — the narrower `index.ts`/`index.js` rule applies only to a directory reached through a `pi.extensions` entry. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
The rule that decides whether pi's `-e <dir>` loader would load anything from a directory is not harness policy — it mirrors pi's own source, and internal/runtime needs the same reserved names and tree-entry rule. Move it, with its tests, into a leaf package that neither internal/harness nor internal/runtime sits below, and give it the verdict the harness needs next: Detect for a local directory, DetectTree for a fetched tree. plugin.json at the directory root is checked first and settles the verdict, so a Claude plugin that bundles a Node MCP server — whose package.json "main" would also satisfy pi's rule — is not read as a pi extension as well. The only behaviour change to `extensions:` is a sharper message when the named directory turns out to be a Claude plugin. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
The runtime is chosen by org/per-repo config, not by the harness, so one
plugin list already runs under whichever runtime the org picks. A
per-runtime key would multiply with Codex and OpenCode, so the
`extensions:` key this branch added is removed and its function moves
under `plugins:`, which grows an object form:
plugins:
- plugins/gopls-lsp
- path: extensions/pi-fff
env: { FFF_MULTIGREP: "1" }
pi: { args: ["--fff-mode", "override"] }
Which runtime loads an entry follows from the directory, not the key:
internal/pluginformat.Detect reads plugin.json first (Claude Code) and
falls back to pi's `-e <dir>` loader rule. Each runtime loads the entries
of its own kind and names and skips the rest, so a harness that lists
both keeps working when the org switches runtime.
env and pi: are options for a runtime that loads the entry as code; on a
Claude plugin they would be silently dropped, so ValidateFilesExist
refuses them there. The kind-dependent checks run after resolve, so a
URL-sourced entry is checked exactly like a local one.
Base composition keeps one directory fetch for both formats. Its lock key
changes from `<dir>/plugin.json` to `<dir>/`, because a plugin entry no
longer has one marker file; existing lock files re-resolve once.
BREAKING CHANGE: `plugins:` entries are now checked for format at load. A
directory that is neither a Claude plugin (plugin.json at its root) nor a
directory pi would load is rejected, as are two entries that would upload
under the same sandbox name. Both used to pass validation and either fail
or silently drop an entry at run time.
Assisted-by: Claude
Signed-off-by: Wayne Sun <gsun@redhat.com>
The pi runtime's extension machinery keeps its names — the manifest field, the sandbox directory and the preflight are pi's own — but the comments that point at the harness key now point at plugins:. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
The user-facing pages keep their walkthrough shape; what changes is the key they document. `plugins:` now carries both formats, so the runtimes table has one row for it, the pi page's Extensions section becomes "Plugins (pi extensions)" with the `pi:` block in its example, and the harness reference documents the object form and which runtime loads what. ADR 0094 is rewritten in place under the title "Plugins are runtime-scoped harness resources": the context gains the fact that the runtime comes from org/per-repo config rather than the harness, and the two plugin families (manifest bundles for Claude Code and Codex, code modules for pi and OpenCode); the decision gains per-entry format detection, namespaced runtime options and the directories-only scope. The filename keeps its slug — no lint hook ties it to the title, and every inbound link stays valid. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
Two comments and an ADR line still read "extensions:" as sentence punctuation, which the key sweep cannot tell from the removed harness key. Reword them; pi's own "pi.extensions" manifest key stays as it is. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
Drop the three places the pi page explained what the Claude Code and dummy runtimes do with a pi-format entry; the runtimes matrix and the field reference say it once. Fix the stale skip-message text in the contributing notes. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
- Accept .claude-plugin/plugin.json as a Claude marker next to fullsend's root plugin.json, so a plugin in Claude Code's own layout is neither refused nor read as a pi extension; docs and ADR 0094 now say which marker is whose (Codex reads .codex-plugin, .claude-plugin and .cursor-plugin manifests). - Scan a Claude plugin's whole tree (commands/, agents/, skills/, hooks/, .mcp.json), skipping symlinks instead of refusing them; a pi tree keeps the refusal. An unknown plugin kind is a scan error. - ValidatePluginDirs: the on-disk plugin checks are a method fullsend lock runs after resolution, so a URL plugin that run would refuse never gets locked; it also re-checks duplicate sandbox basenames across every resolved entry, URL-sourced ones included. - Base-composed plugin fetches fall back to the legacy <dir>/plugin.json index key, so an existing offline cache keeps working until re-locked. - Two entries resolving to one tree with different env/pi options are a resolve error rather than a silent drop; lock replay warns. - DummyPlaybackRuntime names and skips plugins like the other runtimes. - Drop the unused PluginPaths helper; retire the last "extensions" names in comments, the merge table and test names. Review feedback on #6754 (Claude, Grok, Codex). Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
Grok's re-review of the previous round:
- A symlink under a Claude plugin was skipped by the scan but still
carried into the sandbox by the upload. The tree rule is now the same
for both kinds: regular files and directories only, and Detect uses
Lstat so a symlinked marker cannot claim a directory either.
- Remove the manifest-only scanPluginDir and the unused ClaudeMarkerFiles
helper (both would trip the unused linter), and fix the comments that
still described a manifest-only Claude scan.
- PluginSpec.SameOptions compares env/pi with absent and empty treated
alike, so `env: {}` and no `env:` are not a conflict; resolve and lock
replay both use it.
- fullsend lock runs ValidatePluginDirs on the no-URL path too, so
base-composed plugins already local get the same checks.
- Docs: the tree-contents rule is a general plugin rule, and the pi
troubleshooting row names both Claude markers.
Review feedback on #6754 (Grok, round 7).
Assisted-by: Claude
Signed-off-by: Wayne Sun <gsun@redhat.com>
Codex's re-review (gpt-5.6-sol): a Claude plugin is claimed by its marker without a tree walk, so a symlink inside it passed ValidatePluginDirs and fullsend lock, and the injection scan that refuses the same entry only runs with security enabled. pluginformat.TreeEntriesProblem walks the tree with the shared entry rule and validatePluginDir applies it to the Claude kind; the pi detector already did so in its own walk. Also: scan messages say "plugin" for both kinds, comments name both Claude markers, and the tests now pin what they claim — the dispatch test keeps its manifest benign so only the nested file can block, an unknown kind is asserted to error, and the legacy-key test checks the recorded dependency URL. Review feedback on #6754 (Codex, round 8). Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
Rebasing onto main pulled in the codex runtime (ADR 0099/0100), added independently after this branch forked. Its bootstrap still called the pre-fold BootstrapInput.PluginDirs(); the fold replaced that (and Extensions()) with Plugins() []PluginInput. codex does not load any plugin kind yet, so the fix is a like-for-like port: warn-and-skip every declared plugin regardless of format, using the same message pattern Claude Code and pi use post-fold. Also drops a leftover PluginDirs() stub method on a runtime test helper — harmless (Go permits an unused extra method), but stale from the same pre-fold interface. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
352d1a8 to
179d42a
Compare
|
🤖 Finished Review · ❌ Failure (validation failed after 2 iteration(s)) · Started 4:10 PM UTC · Completed 4:31 PM UTC Commit: Runtime: claude · Model: opus → claude-opus-5 · Effort: high · Cost: $16.62 |
Outdated: superseded by later heads (review rounds 5–8 applied); maintainer-approved by rh-hemartin.
|
🤖 Finished Retro · ✅ Success · Started 5:58 PM UTC · Completed 6:13 PM UTC Commit: Runtime: claude · Model: opus → claude-opus-4-6 · Effort: high · Cost: $5.18 |
Retro: PR #6754 — pi extensions under one
|
Summary
Stacked on #6752 (base branch
pi-default-tools-rgfd; retarget tomainonce that merges).Lets a harness ship pi extensions under the existing
plugins:key — one list of directories, each loaded by whichever runtime reads its format — with no manifest, no tool-mapping table and no allowlist bookkeeping. Breaking:plugins:entries becomePluginSpec(string form unchanged) and a local plugin directory must now be aplugin.jsonbundle or a pi-loadable directory.Trust story (ADR 0094). Extensions are harness-repo content with the same trust as
plugins:/scripts:/skills:(org-allowlisted URL base, content-addressed fetch, injection scan of every text file incl.node_modules). They never come from the target repository:defaultProjectTrust: never,--no-approveand--no-extensionsstay exactly as they are — the runner appends vetted-epaths. Because--no-extensions+ explicit-ealready closes the set of code that can register tools, every non-built-in tool the model sees comes from a declared extension, which is why no per-tool declaration is needed. URLs,npm:/git:/ssh:sources (pi would install them from the network at startup) and..segments are rejected at validation, as areenvkeys that would shadow runtime pins (PI_*,FULLSEND_*, provider/proxy variables).Mechanics.
harness:extensions:(string or{path, args, env}form), validation that the directory is something pi can load (index.js/index.ts,package.jsonpi.extensions, or top-level.js/.ts— otherwisepi would load nothing), URL-base resolution generalised from the plugin fetcher (fetchBaseDirwith per-kind validation).Bootstrapuploads each directory to/sandbox/pi-config/extensions/<name>/and records name/path/tree-hash/args/env infullsend-manifest.json; the Claude Code runtime printsExtension "<name>": skipped …and continues (the mirror of pi'splugins:warning).Runpreflight, in the same guarded block as the hook-adapter SHA check and before the agent-writable.envis sourced: each directory must exist, contain nothing but regular files and directories (symlinks are rejected at validation and fail the sandbox guard — pi's-e <dir>loader follows them), and hash (sortedsha256sumlines overfind -type fplus the sorted directory list, identical algorithm in Go and in the shell snippet, cross-tested) to the value recomputed from the host copy — mismatch → exit 96 withfullsend: pi extension "<name>" is missing or was modified. Load order: provider extension →fullsend-hooks.js→ declared extensions in harness order (pi runstool_callhandlers in-eorder, firstblockwins);argsfollow each-e;envis exported last so it can never override the runtime's own exports.tool_allowlist_pretool.pylist extension tool names inFULLSEND_TOOL_ALLOWLISTverbatim (likemcp__*names); first use is logged as[fullsend-hooks] extension tool: <name>and thesession_startroster line ends withextensions=<names>.path|file|pattern|query|command(redacted) instead of nothing.docs/runtimes/pi.md"Extensions", key-support matrix (extensions✓ pi / ignored Claude Code), harness reference/fields, runtime-implementation interface notes, ADR 0094 (0093 is claimed by docs(adr): adopt gh-aw safe outputs reference model #6614).Review round 8 → fix (head
ed414664)Codex (
gpt-5.6-sol) re-reviewed rounds 6–7. One real gap: a Claude plugin is claimed by its marker without a tree walk, so a symlink inside it passedValidatePluginDirs/fullsend lockand was only caught by the injection scan, which runs with security enabled.pluginformat.TreeEntriesProblemnow applies the shared entry rule to the Claude kind at validation (the pi detector already walked). Also: scan messages say "plugin" for both kinds, and three tests were tightened so they pin what they claim (benign manifest in the dispatch test, unknown-kind error, recorded dependency URL on the legacy-key path).Review round 7 → fixes (head
568d0af5)Grok re-reviewed the round-6 commit. Fixed: a symlink under a Claude plugin was skipped by the scan but still uploaded — the no-symlink tree rule now applies to both kinds and
DetectusesLstatso a symlinked marker cannot claim a directory; the manifest-onlyscanPluginDirand the unusedClaudeMarkerFileshelper are gone (dead code);PluginSpec.SameOptionstreats absent and emptyenv/pialike soenv: {}is not a conflict;fullsend lockruns the on-disk plugin checks on the no-URL path too (base-composed plugins); stale "manifest-only" comments and two doc rows corrected.Review round 6 → fixes (head
e9852390)A three-model pass (Claude, Grok, Codex) over the fold commits. Fixed:
.claude-plugin/plugin.json; rootplugin.jsonis fullsend's historical marker. Both now mark a Claude plugin, so a plugin in Claude Code's layout is neither refused nor read as a pi extension. ADR/docs say which marker is whose (Codex discovers.codex-plugin,.claude-pluginand.cursor-pluginmanifests).commands/,agents/,skills/,hooks/,.mcp.json), not just two manifest files; symlinks are skipped for that kind rather than refused. Unknown kind → scan error (Grok).fullsend lockskipped the on-disk plugin checks (Codex + Claude, MEDIUM): the checks are nowHarness.ValidatePluginDirs(), run byValidateFilesExistand bylockafter resolution, and they re-check duplicate sandbox basenames across every resolved entry, URL-sourced ones included (Grok, Claude).<dir>/plugin.jsonindex key, so an existing offline cache keeps working until the next onlinefullsend lock.env/piare a resolve error, not a silent drop; lock replay warns.DummyPlaybackRuntimenames and skips plugins like the other runtimes (Grok); phantomextensionsreferences in a merge table, comments and test names retired (Grok, Codex); unusedPluginPathsdropped (Claude).Not changed, with reasons:
pi.extensionsnegation-pattern evaluation and the ≥1 MiB / binary scan skips are pre-fold design already settled in rounds 2–4; the cross-kind same-basename over-rejection atValidate()is a deliberate over-approximation (documented as "no duplicate basenames across entries"); the repeated tree walks per run are a known cost, not a correctness issue.Review round 5 → one
plugins:key (headdfe61101)Per the review discussion, the separate
extensions:key is gone. The runtime is chosen by org/per-repo config, not the harness, so one list has to serve every runtime:internal/pluginformat(new leaf package):Detect/DetectTreeclassify a directory —plugin.jsonat the root → Claude plugin (checked first, so a Claude plugin that bundles a Node MCP server with apackage.jsonis not misread), otherwise pi's loader rule (moved here fromharness/extension_spec.go) → pi extension; neither → validation error.plugins: []PluginSpec, entriesstring | {path, env, pi: {args}}. Syntax checks stay inValidate(); the format verdict and the kind-gated checks (env/pi:only on a pi-format entry, pi reserved names) run inValidateFilesExistafter URL entries are fetched, so pinned-URL plugins follow theskills:rule for both families. Duplicate paths/basenames are a load error. Base composition uses one fetch kind for both (lock key<dir>/instead of<dir>/plugin.json; old lock entries still map, an offline run against an old lock needsfullsend lockonce).BootstrapInput.Plugins() []PluginInput(name, path,Kind, env, pi args) replacesPluginDirs()+Extensions(). Claude Code loads its kind through the unchanged marketplace path and printsPlugin "<name>": skipped — the Claude Code runtime does not load pi extensions; pi loads its kind through the unchanged upload/preflight/-epath and printsPlugin "<name>": skipped — pi does not support Claude plugins. Injection scan is per kind (manifest files vs whole tree).skills:sourcing, no polyglot directories, directories only — single-file-eentries are a follow-up); harness reference, runtimes matrix,pi.md"Plugins (pi extensions)", runtime-implementation notes.Fleet check: the only
plugins:entry infullsend-ai/agents(plugins/gopls-lsp) hasplugin.jsonand is unaffected.Review round 1 → fixes (head
0d0bfb8d)Claude + Grok found one HIGH (symlinks/directories invisible to the tree hash — an agent-added
index.jssymlink or an emptyskills/directory hijacked or disabled the extension with the hash unchanged) and the MEDIUMs listed in the review; all addressed: symlinks rejected at validation and by the sandbox guard, directories folded into the hash (Go/shell equivalence re-tested), loadability rule now mirrors pi 0.84.4'sresolveLocalExtensionSource/collectPackageResources(package-layout dirs andpackage.jsonmainhandled; pi exits 1Failed to load extensionon a broken one — docs corrected), allowlist bypass removed, env denylist widened toPATH/HOME/LD_*/PYTHON*/NODE_*/SSL_*/*_API_KEY/*_PROXY/PI_*/provider prefixes,argsrestricted to--name[=value]forms excluding pi's own options and values not starting with-/@, dummy runtime warns-and-skips,\rnames refused, scan size caps + sentinel error, duplicate basenames caught at validation, slash-key normalisation, ADR trimmed and reworded (the deny-list, not export order, is the control), threat-model pointer.Review round 2 → fixes (head
529410d3)Round 2 found a second HIGH that predates this PR but that the PR's guarantee depends on: pi loads every
-emodule through jiti, whose transpile cache is/tmp/jitiin the sandbox (pi is root-installed, so jiti falls back toos.tmpdir()), writable by the sandbox user and persistent across iterations; jiti validates only a/* v9-<hash(source)> */\ntrailer, so a poisoned cache body runs while the source — and the hook adapter — hash unchanged. Fixed by exportingJITI_FS_CACHE=falsefromPiRuntime.EnvExports()(re-exported after.envis sourced) and reservingJITI_*in harnessenv:;internal/runtime/testdata/pi/jiti-cache-check.shreproduces the bypass and proves the fix against the pinned pi (aPI_VERSION-bump re-check, likeregen.sh). Also from round 2: the loadability rule now followscollectPackageResourcesexactly (apackage.jsonwith apiobject is judged only bypi.extensions; entries validated and confined to the directory), theargsgrammar matches pi's parser (one value per--flag, none after--flag=value,-/@refused on both sides), one shared non-regular-entry rule for validation, scan and hash, size-skipped files count toward the scan cap, extra env names reserved (TIRITH_*,IFS,HOSTALIASES,OPENSSL_CONF,*_CA_BUNDLE,GOPROXY/GOFLAGS,CLOUDSDK_*,GIT_SSL_*/GIT_CONFIG*), docs reconciled.Review round 3 → fixes (head
774cb97b)Round 3 closed the rest of the loader-environment class: the bundled
cli.jscreates jiti without an explicitalias, so a.env-exportedJITI_ALIASswapped the hook adapter/extension modules with every hash intact (reproduced), andNODE_OPTIONS=--import=…ran code before any extension on the Vertex path (the unset existed only on the OpenAI branch). The run command nowunsetsNODE_OPTIONS NODE_PATHand the wholeJITI_*family jiti reads (18 names, extracted intojiti-cache-check.shfrom the Go source so they cannot drift) right after.envon every provider path, then re-exportsJITI_FS_CACHE=false. Also: env deny-list now coversGIT_*,SSLKEYLOGFILE,CDPATH,PROMPT_COMMAND,JAVA_TOOL_OPTIONS,RUBYOPT,PERL5OPTwith a drift test againstsandbox.reservedCredentialKeys; glob/!pi.extensionsentries are matched against the tree instead of accepted blindly; BOM-prefixedpackage.json, resource-named files, and nested-manifest escapes are handled; reserved sandbox extension names fail at harness validation; docs (architecture.mdorg-mode wording,harness-fields/harness-reference"top level only", theargssentence).Review round 4 (verification) → one fix (head
79a47c6f, rebased onto #6752 /main)Every round-3 item confirmed fixed (the loader-env unset reproduced-and-blocked in the sandbox image, including a
.envthat tries to redefineunset— dash rejects it). One correctness nit fixed: pi treats an entry as a glob only when it contains*or?(hasGlobPattern) and expands braces throughglobSync, so the mirror now uses the same predicate — a bracket-only entry such as[mn]ain.jsis a literal path (rejected when absent, as pi would load nothing) and a brace pattern with*/?is accepted unevaluated rather than wrongly refused.Test plan
go test ./internal/harness/... ./internal/cli/... ./internal/runtime/... ./internal/security/...— new: extension spec unmarshal/validation, base resolution, bootstrap scan of extension trees,TestPiBootstrap_Extensions, run-command goldens (preflight,-eorder, args quoting, env order), Go-vs-shell tree-hash equivalence, Claude warn-and-skip. Pre-existing failures only:TestDummyRuntime_*(local OpenShell gateway) andTestResolveAgentSource_OverrideOnlyEntryUsesAgentsRepoFallback(macOS/varvs/private/var).node --test internal/runtime/pi_extension/*.test.mjs— 21 pass (extension-tool allowlist branch, roster line)make lintcleanfullsend run --runtime pi(this branch's CLI,fullsend-codeimage from fix(pi): activate grep/find/ls and ship rg + fd in the sandbox image #6752) with a scratch harness declaringextensions: [extensions/hello](pre-round-5 key; nowplugins: [extensions/hello]) (a 12-line ESM extension registering ahellotool, notools:frontmatter):Extension "hello": uploaded to sandbox, roster line endsextensions=hello,[fullsend-hooks] extension tool: helloon first use, the tool returnedHELLO fullsend FROM EXTENSIONand the agent finished (haiku, $0.01). In the kept sandbox thefind | sort | sha256sumpipeline reproduced the manifest's host-computed hash exactly, and appending one line toindex.jschanged it (the preflight's exit-96 condition).Design origin: research on pi extensions for the fleet (audited pi-fff, pi-subagents, workflow engines, pi-web-access, pi-background-tasks, pi-lens — none fleet-default; this key is the opt-in mechanism). Refs #6464, #6550, #6527.