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..686a19c2 100644 --- a/cmd/pilotctl/appstore.go +++ b/cmd/pilotctl/appstore.go @@ -1184,6 +1184,34 @@ 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"} { + // 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) // #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 { // #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 { // #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) + } + } + if source == installSourceLocal { // Plant the sentinel before the atomic rename so the moment // the dir appears under InstallRoot it's already tagged 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=