From 50e40e0d4799698acf35a295de10769cb06c1e02 Mon Sep 17 00:00:00 2001 From: Alex Godoroja Date: Mon, 22 Jun 2026 11:13:15 -0700 Subject: [PATCH 1/4] go.mod: bump app-store to the proc.exec version Repoint the pinned app-store dependency to the commit that adds the proc.exec capability, so the daemon's manifest validation accepts CLI apps (which ship a proc.exec grant scoped to one command). Without the bump, the deployed daemon validates against a capability vocabulary that lacks proc.exec and rejects them. No daemon code changes: pilotctl/supervisor already delegate validation to app-store's manifest.Validate(). Adds a regression test asserting a proc.exec manifest validates (and that a wildcard target is still rejected). Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/pilotctl/zz_procexec_test.go | 53 ++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +-- 3 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 cmd/pilotctl/zz_procexec_test.go diff --git a/cmd/pilotctl/zz_procexec_test.go b/cmd/pilotctl/zz_procexec_test.go new file mode 100644 index 00000000..fd50e16e --- /dev/null +++ b/cmd/pilotctl/zz_procexec_test.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/pilot-protocol/app-store/pkg/manifest" +) + +// TestProcExecCapabilityAccepted pins that this daemon's pinned app-store knows +// the proc.exec capability. CLI apps ship a proc.exec grant scoped to one +// command; before the app-store bump the daemon validated every manifest with a +// vocabulary that lacked proc.exec and would reject them as "not a known +// capability". This is the regression guard for the bump. +func TestProcExecCapabilityAccepted(t *testing.T) { + mk := func(grants []any) *manifest.Manifest { + raw, _ := json.Marshal(map[string]any{ + "id": "io.pilot.gh", + "app_version": "0.1.0", + "manifest_version": 1, + "binary": map[string]any{"runtime": "go", "path": "bin/app", "sha256": strings.Repeat("a", 64)}, + "grants": grants, + "protection": "guarded", + "store": map[string]any{"publisher": "ed25519:AAAAB3NzaC1yc2EAAAADAQABAAABAQDXX0000000", "signature": "deadbeef"}, + }) + m, err := manifest.Parse(raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + return m + } + + // A CLI app's manifest (proc.exec scoped to the command) must validate. + ok := mk([]any{ + map[string]any{"cap": "proc.exec", "target": "gh"}, + map[string]any{"cap": "audit.log", "target": "*"}, + }) + if errs := ok.Validate(); len(errs) != 0 { + t.Fatalf("proc.exec manifest must validate against the pinned app-store: %v", errs) + } + + // The hardened target still rejects a wildcard ("run anything"). + bad := mk([]any{ + map[string]any{"cap": "proc.exec", "target": "*"}, + map[string]any{"cap": "audit.log", "target": "*"}, + }) + if errs := bad.Validate(); len(errs) == 0 { + t.Fatal("proc.exec target '*' must be rejected by the pinned app-store (hardened target)") + } +} diff --git a/go.mod b/go.mod index f682a659..86aa6476 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.11 require ( github.com/coder/websocket v1.8.15 - github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260616142430-8edfed7efa72 + github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260622180016-07b4170265dc github.com/pilot-protocol/beacon v0.2.6 github.com/pilot-protocol/common v0.5.5 github.com/pilot-protocol/dataexchange v0.2.1-beta.1.0.20260615113607-fac933edea98 diff --git a/go.sum b/go.sum index 6add17d3..aac950b7 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNU github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= -github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260616142430-8edfed7efa72 h1:vDiQ7ZheKIzlNqfviu5zeQzGVTMP63k1hC5HodEuyeQ= -github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260616142430-8edfed7efa72/go.mod h1:leZPtX43gE2JB7xeljexXri81g6qhdZfYExLtzI+bhg= +github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260622180016-07b4170265dc h1:Ze7h3rEPMhFaAyjNH9riySBs8HEeeoB3wODwtoLQ4Eo= +github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260622180016-07b4170265dc/go.mod h1:leZPtX43gE2JB7xeljexXri81g6qhdZfYExLtzI+bhg= github.com/pilot-protocol/beacon v0.2.6 h1:grxwaVyPRUT0W6coyjYfNkO0rpzOIrwrKn94S21DuVE= github.com/pilot-protocol/beacon v0.2.6/go.mod h1:I/UhEv097g1z/qtAVDZbEhf3R5tzM0Dp71vGHah52A4= github.com/pilot-protocol/common v0.5.5 h1:mnv3q84alVaotGD+Qxfo4ECFEquqsUwrI3mjKIGUKFY= From d5b35105833eedde6ae9c919fb84fb534d6a524b Mon Sep 17 00:00:00 2001 From: Alex Godoroja Date: Mon, 22 Jun 2026 13:05:11 -0700 Subject: [PATCH 2/4] feat(appstore): carry install.json/install.sh on install + wire trust anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes the native-CLI (proc.exec) app delivery needs, surfaced by an end-to-end smol machines install via pilotctl: 1. pilotctl appstore install now stages install.json + install.sh into $APP (previously only manifest.json + the binary), so an asset-delivering cli adapter can read $APP/install.json and fetch/verify/stage its binaries from the R2 artifact registry. Covered by the bundle sha, no new trust surface. 2. The daemon now populates manifest.TrustedPublishers from PILOT_TRUSTED_PUBLISHERS (the reviewed publisher registry) and honors PILOT_APPSTORE_ROOT. app-store#23 enforces the trust anchor for non-sideloaded installs, but nothing wired the list — so the proc.exec daemon skipped EVERY catalogue app. Without this, upgrading the daemon to the proc.exec version bricks the existing app store. Verified: catalogue install → daemon spawn (sideloaded=false) → adapter stages smolvm from R2 → pilotctl appstore call runs a real microVM. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/daemon/main.go | 17 +++++++++++++++++ cmd/pilotctl/appstore.go | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 2d87514b..0848b8ce 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -25,6 +25,7 @@ import ( // L11 plugin imports — cmd/daemon (L12) is the only place these // are allowed. The daemon proper imports only pkg/coreapi // interfaces. + "github.com/pilot-protocol/app-store/pkg/manifest" "github.com/pilot-protocol/app-store/plugin/appstore" "github.com/pilot-protocol/dataexchange" "github.com/pilot-protocol/eventstream" @@ -346,6 +347,22 @@ func main() { if home, herr := os.UserHomeDir(); herr == nil { appstoreInstallRoot = filepath.Join(home, ".pilot", "apps") } + if r := os.Getenv("PILOT_APPSTORE_ROOT"); r != "" { + appstoreInstallRoot = r + } + // Trust anchor (G4′): the supervisor refuses to spawn a non-sideloaded app + // whose publisher is not on manifest.TrustedPublishers. Nothing populated it + // before, so enforcement skipped every catalogue app. Wire it from + // PILOT_TRUSTED_PUBLISHERS (comma-separated ed25519: ids) — in + // production this list is the reviewed publisher registry. + if tp := strings.TrimSpace(os.Getenv("PILOT_TRUSTED_PUBLISHERS")); tp != "" { + for _, p := range strings.Split(tp, ",") { + if p = strings.TrimSpace(p); p != "" { + manifest.TrustedPublishers = append(manifest.TrustedPublishers, p) + } + } + log.Printf("appstore: %d trusted publisher(s) loaded from PILOT_TRUSTED_PUBLISHERS", len(manifest.TrustedPublishers)) + } // The app-usage telemetry emitter shares the daemon's identity file // and telemetry URL. When consent is off (empty URL) the client is // a permanent no-op — no goroutines, no dials, no buffering. diff --git a/cmd/pilotctl/appstore.go b/cmd/pilotctl/appstore.go index 70bffe69..5973bfb6 100644 --- a/cmd/pilotctl/appstore.go +++ b/cmd/pilotctl/appstore.go @@ -1184,6 +1184,26 @@ func cmdAppStoreInstall(args []string) { "staged binary sha256 mismatch: manifest=%s staged=%s", m.Binary.SHA256, got) } + // Carry the native-delivery install spec (and its human-readable script) into + // $APP when the bundle ships them. A cli adapter with assets reads + // $APP/install.json at startup to fetch + verify + stage its binaries from the + // R2 artifact registry. These files are covered by the bundle's sha (verified + // above at the tarball level), so copying them adds no new trust surface. + for _, aux := range []string{"install.json", "install.sh"} { + src := filepath.Join(bundleDir, aux) + if _, err := os.Stat(src); err != nil { + continue // not an asset-delivering app + } + mode := os.FileMode(0o644) + if aux == "install.sh" { + mode = 0o755 + } + if err := copyFile(src, filepath.Join(stagingDir, aux), mode); err != nil { + _ = os.RemoveAll(stagingDir) + fatalHint("io_error", "check install root permissions", "copy %s: %v", aux, err) + } + } + if source == installSourceLocal { // Plant the sentinel before the atomic rename so the moment // the dir appears under InstallRoot it's already tagged From 23ab9589d9830cc64105a54ad90d2efac9f23b26 Mon Sep 17 00:00:00 2001 From: Alex Godoroja Date: Mon, 22 Jun 2026 15:59:17 -0700 Subject: [PATCH 3/4] fix(gosec): resolve install.json/install.sh aux paths via resolveUnder (G304) Mirror the binary-copy containment pattern: resolveUnder cleans the join and verifies the path stays under the bundle/staging root, clearing the two gosec 'path traversal via taint analysis' (G304) alerts on the aux-file carry. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/pilotctl/appstore.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/cmd/pilotctl/appstore.go b/cmd/pilotctl/appstore.go index 5973bfb6..3662d9db 100644 --- a/cmd/pilotctl/appstore.go +++ b/cmd/pilotctl/appstore.go @@ -1190,7 +1190,15 @@ func cmdAppStoreInstall(args []string) { // R2 artifact registry. These files are covered by the bundle's sha (verified // above at the tarball level), so copying them adds no new trust surface. for _, aux := range []string{"install.json", "install.sh"} { - src := filepath.Join(bundleDir, aux) + // Resolve both ends through the same containment guard the binary copy + // uses: aux is a constant allow-list entry, and resolveUnder cleans the + // join and verifies it stays under the root — so neither path can escape. + src, serr := resolveUnder(bundleDir, aux) + dst, derr := resolveUnder(stagingDir, aux) + if serr != nil || derr != nil { + _ = os.RemoveAll(stagingDir) + fatalHint("internal_error", "aux install file path escaped the bundle/staging root", "resolve %s: %v / %v", aux, serr, derr) + } if _, err := os.Stat(src); err != nil { continue // not an asset-delivering app } @@ -1198,7 +1206,7 @@ func cmdAppStoreInstall(args []string) { if aux == "install.sh" { mode = 0o755 } - if err := copyFile(src, filepath.Join(stagingDir, aux), mode); err != nil { + if err := copyFile(src, dst, mode); err != nil { _ = os.RemoveAll(stagingDir) fatalHint("io_error", "check install root permissions", "copy %s: %v", aux, err) } From a6e0f632bbef392c86be813342f10719a8418abf Mon Sep 17 00:00:00 2001 From: Alex Godoroja Date: Mon, 22 Jun 2026 16:07:46 -0700 Subject: [PATCH 4/4] fix(gosec): suppress G703 taint false-positives on aux-file carry The aux paths are resolveUnder-confined (bundle/staging roots) and aux is a constant allow-list ('install.json'/'install.sh'), so no traversal is possible. gosec's SSA taint analyzer can't see resolveUnder as a sanitizer, so annotate the file ops + the new staging-cleanup with #nosec G703 (the same pattern this file already uses for the binary copy). --- cmd/pilotctl/appstore.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/pilotctl/appstore.go b/cmd/pilotctl/appstore.go index 3662d9db..686a19c2 100644 --- a/cmd/pilotctl/appstore.go +++ b/cmd/pilotctl/appstore.go @@ -1196,18 +1196,18 @@ func cmdAppStoreInstall(args []string) { src, serr := resolveUnder(bundleDir, aux) dst, derr := resolveUnder(stagingDir, aux) if serr != nil || derr != nil { - _ = os.RemoveAll(stagingDir) + _ = os.RemoveAll(stagingDir) // #nosec G703 -- stagingDir is appStoreRoot()/.staging (m.ID reverse-DNS validated), confined to the install root; cleanup of our own dir fatalHint("internal_error", "aux install file path escaped the bundle/staging root", "resolve %s: %v / %v", aux, serr, derr) } - if _, err := os.Stat(src); err != nil { + if _, err := os.Stat(src); err != nil { // #nosec G703 -- src is resolveUnder(bundleDir, ), proven to stay under the bundle root above; no traversal continue // not an asset-delivering app } mode := os.FileMode(0o644) if aux == "install.sh" { mode = 0o755 } - if err := copyFile(src, dst, mode); err != nil { - _ = os.RemoveAll(stagingDir) + if err := copyFile(src, dst, mode); err != nil { // #nosec G703 -- src/dst are resolveUnder-confined (bundle/staging roots); aux is a constant allow-list entry, so neither can escape + _ = os.RemoveAll(stagingDir) // #nosec G703 -- stagingDir is the confined install-root staging dir; cleanup of our own dir fatalHint("io_error", "check install root permissions", "copy %s: %v", aux, err) } }