From 874d51199f5b6f8795253f34cedd922053740371 Mon Sep 17 00:00:00 2001 From: Alexgodoroja Date: Mon, 24 Aug 2026 21:23:58 -0700 Subject: [PATCH 1/4] feat(apps): reconcile managed apps from the fleet desired state Nodes converge their installed apps toward a desired-set document the authority writes into the fleet state mirror. The document arrives as an ordinary signed, revision-fenced state mutation, so managed app install adds no new command kind, endpoint, or inbound channel. The two-root design is the grant boundary. Grants exist only inside a signed bundle, never in the catalogue, so a node installs an unreviewed app into a staging root the supervisor does not scan: the binary is present, its manifest can be read and reported, and it cannot run. Promotion into the live install root happens only once the desired document accepts every grant the manifest declares -- and a catalogue republish that widens them demotes the app rather than silently keeping the wider capability set. Installation itself shells out to the pilotctl beside the daemon. pilotctl owns the only implementation of the catalogue trust chain, and a second verifier for a security boundary is the one thing that must not exist; AppInstaller keeps that swappable if the path is later extracted. The inventory is republished only when it actually changes: it lives in the state mirror, so a ticking timestamp would churn the revision the console fences its mutations on and invalidate in-flight installs. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/daemon/main.go | 51 ++ internal/enterprisecontrol/control.go | 43 ++ internal/enterprisecontrol/fleet_apps.go | 429 ++++++++++++++++ internal/enterprisecontrol/fleet_apps_test.go | 471 ++++++++++++++++++ internal/managedsdk/authority/fleet_apps.go | 264 ++++++++++ 5 files changed, 1258 insertions(+) create mode 100644 internal/enterprisecontrol/fleet_apps.go create mode 100644 internal/enterprisecontrol/fleet_apps_test.go create mode 100644 internal/managedsdk/authority/fleet_apps.go diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index bb8c7280..efff87a9 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -11,6 +11,7 @@ import ( "log" "log/slog" "os" + "os/exec" "os/signal" "path/filepath" "strconv" @@ -568,6 +569,23 @@ func main() { }() } + if enterpriseControls.HasAppReconcile() { + appInstaller := enterprisecontrol.PilotctlInstaller{BinaryPath: pilotctlBinaryPath()} + reconcileApps(fleetControlCtx, enterpriseControls, appInstaller) + go func() { + ticker := time.NewTicker(enterpriseControls.AppReconcileInterval()) + defer ticker.Stop() + for { + select { + case <-fleetControlCtx.Done(): + return + case <-ticker.C: + reconcileApps(fleetControlCtx, enterpriseControls, appInstaller) + } + } + }() + } + receiptExportCtx, receiptExportCancel := context.WithCancel(context.Background()) if enterpriseControls.HasReceiptExport() { if err := enterpriseControls.ExportReceiptsOnce(receiptExportCtx); err != nil { @@ -769,6 +787,39 @@ func synchronizeFleetControl(ctx context.Context, controls *enterprisecontrol.Ru } } +// pilotctlBinaryPath resolves the pilotctl that ships beside this daemon. +// Preferring the sibling binary over $PATH keeps the verified install path +// pinned to the same release as the daemon rather than to whatever a user +// happens to have earlier in their environment. +func pilotctlBinaryPath() string { + if executable, err := os.Executable(); err == nil { + sibling := filepath.Join(filepath.Dir(executable), "pilotctl") + if info, statErr := os.Stat(sibling); statErr == nil && !info.IsDir() { + return sibling + } + } + if resolved, err := exec.LookPath("pilotctl"); err == nil { + return resolved + } + return "pilotctl" +} + +// reconcileApps converges installed apps toward the authority's desired set. +// A failure here must never disturb policy enforcement or the state mirror, so +// it is logged and retried on the next tick rather than propagated. +func reconcileApps(ctx context.Context, controls *enterprisecontrol.Runtime, installer enterprisecontrol.AppInstaller) { + result, err := controls.ReconcileApps(ctx, installer) + if err != nil { + slog.Warn("managed app reconcile failed", "err", err) + return + } + if result.Installed+result.Staged+result.Removed+result.Failed > 0 { + slog.Info("managed apps reconciled", + "desired", result.Desired, "installed", result.Installed, + "awaiting_grants", result.Staged, "removed", result.Removed, "failed", result.Failed) + } +} + func synchronizeFleetState(ctx context.Context, controls *enterprisecontrol.Runtime) { result, err := controls.SyncFleetState(ctx) if err != nil { diff --git a/internal/enterprisecontrol/control.go b/internal/enterprisecontrol/control.go index 04b833cf..1f2cc252 100644 --- a/internal/enterprisecontrol/control.go +++ b/internal/enterprisecontrol/control.go @@ -48,6 +48,7 @@ type Config struct { Receipts *ReceiptConfig `json:"receipts,omitempty"` Rollout *RolloutConfig `json:"rollout,omitempty"` Fleet *FleetConfig `json:"fleet,omitempty"` + Apps *AppsConfig `json:"apps,omitempty"` OutboundDecisions *OutboundDecisionConfig `json:"outbound_decisions,omitempty"` ActionControl *ActionControlConfig `json:"action_control,omitempty"` ContentInspection *ContentInspectionConfig `json:"content_inspection,omitempty"` @@ -277,6 +278,12 @@ type Runtime struct { fleetStateRevision uint64 fleetStateRootHash string fleetStatePendingResults []authority.FleetStateMutationResult + appsEnabled bool + appsInterval time.Duration + appsInstallRoot string + appsStagingRoot string + appsMu sync.Mutex + appsManaged map[string]struct{} outboundClient *decisionhttp.Client outboundAgentID string outboundKeyID string @@ -636,6 +643,42 @@ func Load(path string) (*Runtime, error) { } } } + if config.Apps != nil && config.Apps.Enabled { + // Apps ride on the state mirror: the desired document arrives as an + // ordinary signed state mutation, so without state sync there is no + // channel to receive one and nothing to reconcile toward. + if !runtime.fleetStateEnabled { + return nil, fmt.Errorf("enterprise control: app reconciliation requires fleet state sync") + } + home, homeErr := os.UserHomeDir() + if homeErr != nil && (config.Apps.InstallRoot == "" || config.Apps.StagingRoot == "") { + return nil, fmt.Errorf("enterprise control: app roots: %w", homeErr) + } + installRoot := strings.TrimSpace(config.Apps.InstallRoot) + if installRoot == "" { + installRoot = filepath.Join(home, ".pilot", "apps") + } + stagingRoot := strings.TrimSpace(config.Apps.StagingRoot) + if stagingRoot == "" { + // Deliberately a sibling of the install root, never inside it: the + // supervisor scans the install root, and an app awaiting grant + // acceptance must be somewhere it will not be spawned from. + stagingRoot = filepath.Join(home, ".pilot", "apps-pending") + } + if installRoot == stagingRoot { + return nil, fmt.Errorf("enterprise control: app staging_root must differ from install_root") + } + if withinDirectory(installRoot, stagingRoot) { + return nil, fmt.Errorf("enterprise control: app staging_root must not sit inside install_root") + } + runtime.appsEnabled = true + runtime.appsInstallRoot, runtime.appsStagingRoot = installRoot, stagingRoot + runtime.appsManaged = make(map[string]struct{}) + runtime.appsInterval = time.Duration(config.Apps.ReconcileIntervalSeconds) * time.Second + if runtime.appsInterval == 0 { + runtime.appsInterval = 30 * time.Second + } + } if config.DataExchange != nil { runtime.dataEnabled = true runtime.dataRequired = config.DataExchange.RequireGoverned diff --git a/internal/enterprisecontrol/fleet_apps.go b/internal/enterprisecontrol/fleet_apps.go new file mode 100644 index 00000000..4b3f66b7 --- /dev/null +++ b/internal/enterprisecontrol/fleet_apps.go @@ -0,0 +1,429 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package enterprisecontrol + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + appmanifest "github.com/pilot-protocol/app-store/pkg/manifest" + "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" +) + +// AppsConfig enables managed app reconciliation. It is opt-in for the same +// reason fleet state sync is: an unmanaged node must never acquire software +// because some authority asked it to. +type AppsConfig struct { + Enabled bool `json:"enabled,omitempty"` + InstallRoot string `json:"install_root,omitempty"` + StagingRoot string `json:"staging_root,omitempty"` + InstallerPath string `json:"installer_path,omitempty"` + ReconcileIntervalSeconds int64 `json:"reconcile_interval_seconds,omitempty"` +} + +// AppReconcileResult is bounded operational information for daemon logs. App +// identifiers are catalogue-public, so they may cross this boundary; local +// paths and manifest contents may not. +type AppReconcileResult struct { + Desired int + Installed int + Staged int + Removed int + Failed int +} + +// AppInstaller performs the actual bundle fetch, verification, and extraction. +// +// It is an interface because the verified install path currently lives in +// pilotctl's `package main` and cannot be linked into the daemon. Reconciliation +// logic is therefore testable without a real network or a real pilotctl binary, +// and the concrete implementation can later be swapped for an extracted +// library without touching anything here. +type AppInstaller interface { + // Install places appID at the requested version beneath root, performing + // the same catalogue signature and sha256 checks an operator would get at + // the keyboard. + Install(ctx context.Context, appID, version, root string) error + // Remove deletes appID from root. Removing an app that is not present is + // not an error. + Remove(ctx context.Context, appID, root string) error +} + +// PilotctlInstaller drives the pilotctl binary that ships beside the daemon. +// +// Shelling out is deliberate rather than convenient: pilotctl owns the only +// implementation of the catalogue trust chain (publisher signature, per-platform +// bundle pin, sha256 verification, sideload clamping). Reimplementing that here +// would create a second, subtly different verifier — the one outcome that must +// not happen for a security boundary. +type PilotctlInstaller struct { + BinaryPath string + Timeout time.Duration +} + +func (installer PilotctlInstaller) timeout() time.Duration { + if installer.Timeout > 0 { + return installer.Timeout + } + return 10 * time.Minute +} + +func (installer PilotctlInstaller) Install(ctx context.Context, appID, version, root string) error { + ctx, cancel := context.WithTimeout(ctx, installer.timeout()) + defer cancel() + // --force lets an install replace a wrong-version copy in place; the + // catalogue signature and sha256 gates still run either way. + command := exec.CommandContext(ctx, installer.BinaryPath, "appstore", "install", appID, "--force") + command.Env = append(os.Environ(), "PILOT_APPSTORE_ROOT="+root) + output, err := command.CombinedOutput() + if err != nil { + return fmt.Errorf("install %s: %w: %s", appID, err, boundedInstallerOutput(output)) + } + return nil +} + +func (installer PilotctlInstaller) Remove(ctx context.Context, appID, root string) error { + target := filepath.Join(root, appID) + if _, err := os.Stat(target); os.IsNotExist(err) { + return nil + } + ctx, cancel := context.WithTimeout(ctx, installer.timeout()) + defer cancel() + command := exec.CommandContext(ctx, installer.BinaryPath, "appstore", "uninstall", appID, "--yes") + command.Env = append(os.Environ(), "PILOT_APPSTORE_ROOT="+root) + output, err := command.CombinedOutput() + if err != nil { + return fmt.Errorf("uninstall %s: %w: %s", appID, err, boundedInstallerOutput(output)) + } + return nil +} + +// boundedInstallerOutput keeps a failing subprocess's tail for the daemon log +// without letting an unbounded child write flood it. +func boundedInstallerOutput(output []byte) string { + const limit = 512 + text := strings.TrimSpace(string(output)) + if len(text) > limit { + text = text[len(text)-limit:] + } + return strings.ReplaceAll(text, "\n", " ") +} + +// HasAppReconcile reports whether this node reconciles managed apps. +// +// It requires the state mirror rather than the full fleet control channel: +// the desired document is delivered as a state mutation and then read from +// disk, so reconciliation is correct even during a spell when the authority is +// unreachable. Config load already refuses to enable apps without state sync. +func (runtime *Runtime) HasAppReconcile() bool { + return runtime != nil && runtime.appsEnabled && runtime.fleetStateEnabled && runtime.fleetStateRoot != "" +} + +func (runtime *Runtime) AppReconcileInterval() time.Duration { + if !runtime.HasAppReconcile() { + return 0 + } + return runtime.appsInterval +} + +// ReconcileApps converges the node's installed apps toward the authority's +// desired set and republishes what it observes. +// +// The two-root design is the grant boundary. An app whose declared grants the +// tenant has not accepted is installed into the staging root, which the +// supervisor does not scan — so its binary exists, its manifest can be read and +// reported, and it cannot run. Promotion into the live install root happens +// only once the desired document carries an acceptance covering every grant the +// manifest declares. A catalogue that later widens an app's grants demotes it +// back to staging on the next pass rather than silently gaining capability. +func (runtime *Runtime) ReconcileApps(ctx context.Context, installer AppInstaller) (AppReconcileResult, error) { + if !runtime.HasAppReconcile() { + return AppReconcileResult{}, fmt.Errorf("enterprise control: app reconciliation is not configured") + } + if installer == nil { + return AppReconcileResult{}, fmt.Errorf("enterprise control: app installer is required") + } + runtime.appsMu.Lock() + defer runtime.appsMu.Unlock() + + runtime.mu.Lock() + tenantID, agentID := runtime.tenantID, runtime.rolloutAgentID + stateRoot, installRoot, stagingRoot := runtime.fleetStateRoot, runtime.appsInstallRoot, runtime.appsStagingRoot + runtime.mu.Unlock() + + desired, err := readDesiredApps(filepath.Join(stateRoot, authority.FleetAppsDocumentPath), tenantID, agentID) + if err != nil { + return AppReconcileResult{}, err + } + if err := secureDirectory(installRoot); err != nil { + return AppReconcileResult{}, fmt.Errorf("enterprise control: app install root: %w", err) + } + if err := secureDirectory(stagingRoot); err != nil { + return AppReconcileResult{}, fmt.Errorf("enterprise control: app staging root: %w", err) + } + + result := AppReconcileResult{Desired: len(desired.Desired)} + observed := make([]authority.FleetAppState, 0, len(desired.Desired)) + wanted := make(map[string]struct{}, len(desired.Desired)) + now := time.Now().UTC() + + for _, spec := range desired.Desired { + wanted[spec.ID] = struct{}{} + state := runtime.reconcileOneApp(ctx, installer, spec, installRoot, stagingRoot, now) + switch state.Status { + case authority.FleetAppInstalled: + result.Installed++ + case authority.FleetAppGrantBlocked: + result.Staged++ + case authority.FleetAppFailed: + result.Failed++ + } + observed = append(observed, state) + } + + // Anything this node installed under management but no longer wants is + // withdrawn from both roots. Apps a local operator installed by hand are + // deliberately untouched: management adds and removes what it was asked + // to, and does not assert ownership of the whole install root. + for _, appID := range managedAppIDs(stagingRoot) { + if _, keep := wanted[appID]; keep { + continue + } + if err := installer.Remove(ctx, appID, stagingRoot); err == nil { + result.Removed++ + } + } + for _, appID := range previouslyManaged(runtime.appsManaged, wanted) { + if err := installer.Remove(ctx, appID, installRoot); err == nil { + result.Removed++ + } + } + runtime.appsManaged = wanted + + sort.Slice(observed, func(i, j int) bool { return observed[i].ID < observed[j].ID }) + report := authority.FleetAppsReport{ + Version: authority.FleetAppsVersion, TenantID: tenantID, AgentID: agentID, + Apps: observed, ObservedAt: now.Unix(), + } + if err := writeObservedApps(filepath.Join(stateRoot, authority.FleetAppsReportPath), report); err != nil { + return result, err + } + return result, nil +} + +func (runtime *Runtime) reconcileOneApp(ctx context.Context, installer AppInstaller, spec authority.FleetAppSpec, installRoot, stagingRoot string, now time.Time) authority.FleetAppState { + state := authority.FleetAppState{ID: spec.ID, Version: spec.Version, ObservedAt: now.Unix()} + + live, liveErr := readAppManifest(filepath.Join(installRoot, spec.ID)) + staged, stagedErr := readAppManifest(filepath.Join(stagingRoot, spec.ID)) + + // Already live at the right version with an acceptance that still covers + // what it declares: nothing to do. + if liveErr == nil && live.AppVersion == spec.Version { + declared := manifestGrants(live) + state.DeclaredGrants, state.BinarySHA256 = declared, live.Binary.SHA256 + if authority.GrantsCovered(declared, spec.AcceptedGrants) { + state.Status = authority.FleetAppInstalled + return state + } + // Acceptance no longer covers the manifest. Demote rather than let a + // widened grant set keep running. + if err := installer.Remove(ctx, spec.ID, installRoot); err != nil { + state.Status, state.Detail = authority.FleetAppFailed, "demote_failed" + return state + } + liveErr, live = fmt.Errorf("demoted"), appmanifest.Manifest{} + } + + // Ensure a staged copy at the requested version exists so the manifest — + // the only truthful source of grants — can be read. + if stagedErr != nil || staged.AppVersion != spec.Version { + if err := installer.Install(ctx, spec.ID, spec.Version, stagingRoot); err != nil { + state.Status, state.Detail = authority.FleetAppFailed, "install_failed" + return state + } + staged, stagedErr = readAppManifest(filepath.Join(stagingRoot, spec.ID)) + if stagedErr != nil { + state.Status, state.Detail = authority.FleetAppFailed, "manifest_unreadable" + return state + } + } + + declared := manifestGrants(staged) + state.DeclaredGrants, state.BinarySHA256 = declared, staged.Binary.SHA256 + if staged.AppVersion != "" { + state.Version = staged.AppVersion + } + + if !authority.GrantsCovered(declared, spec.AcceptedGrants) { + // Held deliberately: installed, readable, reported, not running. + state.Status, state.Detail = authority.FleetAppGrantBlocked, "awaiting_grant_acceptance" + return state + } + + // Accepted — promote into the supervisor's scan root. + if err := installer.Install(ctx, spec.ID, spec.Version, installRoot); err != nil { + state.Status, state.Detail = authority.FleetAppFailed, "promote_failed" + return state + } + if err := installer.Remove(ctx, spec.ID, stagingRoot); err != nil { + // A leftover staged copy is inert; it must not fail the reconcile. + state.Detail = "staging_cleanup_deferred" + } + state.Status = authority.FleetAppInstalled + return state +} + +func readDesiredApps(path, tenantID, agentID string) (authority.FleetAppsDocument, error) { + var document authority.FleetAppsDocument + raw, err := os.ReadFile(path) // #nosec G304 -- path is built from the runtime's own confined state root. + if os.IsNotExist(err) { + // No desired set is a valid state meaning "manage no apps here". + return authority.FleetAppsDocument{Version: authority.FleetAppsVersion, TenantID: tenantID, AgentID: agentID}, nil + } + if err != nil { + return document, fmt.Errorf("enterprise control: read desired apps: %w", err) + } + if err := json.Unmarshal(raw, &document); err != nil { + return document, fmt.Errorf("enterprise control: parse desired apps: %w", err) + } + if err := document.Validate(); err != nil { + return authority.FleetAppsDocument{}, err + } + // The document arrives inside a signed, revision-fenced mutation, but it + // then sits on local disk. Re-checking that it still addresses this node + // costs nothing and refuses a file copied from another machine. + if document.TenantID != tenantID || document.AgentID != agentID { + return authority.FleetAppsDocument{}, fmt.Errorf("enterprise control: desired apps document addresses another node") + } + return document, nil +} + +// writeObservedApps republishes the inventory only when something an operator +// would care about actually changed. +// +// This is not an optimization. The report lives inside the fleet state mirror, +// so every rewrite bumps the node's state revision — and the console fences its +// mutations on that revision. Refreshing a timestamp every reconcile tick would +// invalidate an operator's in-flight install before they could confirm it. +func writeObservedApps(path string, report authority.FleetAppsReport) error { + if err := report.Validate(); err != nil { + return err + } + if existing, err := os.ReadFile(path); err == nil { // #nosec G304 -- confined state root. + var previous authority.FleetAppsReport + if json.Unmarshal(existing, &previous) == nil && sameObservedApps(previous, report) { + return nil + } + } + encoded, err := json.MarshalIndent(report, "", " ") + if err != nil { + return err + } + return atomicWriteSecureBytes(path, append(encoded, '\n')) +} + +// sameObservedApps compares two inventories ignoring observation timestamps, +// which advance on every tick regardless of whether anything happened. +func sameObservedApps(previous, current authority.FleetAppsReport) bool { + if previous.Version != current.Version || previous.TenantID != current.TenantID || + previous.AgentID != current.AgentID || len(previous.Apps) != len(current.Apps) { + return false + } + for index := range current.Apps { + before, after := previous.Apps[index], current.Apps[index] + if before.ID != after.ID || before.Version != after.Version || before.Status != after.Status || + before.Detail != after.Detail || before.BinarySHA256 != after.BinarySHA256 || + len(before.DeclaredGrants) != len(after.DeclaredGrants) { + return false + } + for grantIndex := range after.DeclaredGrants { + if before.DeclaredGrants[grantIndex] != after.DeclaredGrants[grantIndex] { + return false + } + } + } + return true +} + +func readAppManifest(directory string) (appmanifest.Manifest, error) { + raw, err := os.ReadFile(filepath.Join(directory, "manifest.json")) // #nosec G304 -- directory is confined to a managed app root. + if err != nil { + return appmanifest.Manifest{}, err + } + parsed, err := appmanifest.Parse(raw) + if err != nil { + return appmanifest.Manifest{}, err + } + if errs := parsed.Validate(); len(errs) > 0 { + return appmanifest.Manifest{}, fmt.Errorf("manifest validation: %v", errs[0]) + } + return *parsed, nil +} + +func manifestGrants(parsed appmanifest.Manifest) []authority.FleetAppGrant { + grants := make([]authority.FleetAppGrant, 0, len(parsed.Grants)) + for _, grant := range parsed.Grants { + grants = append(grants, authority.FleetAppGrant{Cap: grant.Cap, Target: grant.Target}) + } + sort.Slice(grants, func(i, j int) bool { + if grants[i].Cap != grants[j].Cap { + return grants[i].Cap < grants[j].Cap + } + return grants[i].Target < grants[j].Target + }) + return grants +} + +// managedAppIDs lists app directories under a root the runtime fully owns. +func managedAppIDs(root string) []string { + entries, err := os.ReadDir(root) + if err != nil { + return nil + } + ids := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() && !strings.HasPrefix(entry.Name(), ".") { + ids = append(ids, entry.Name()) + } + } + sort.Strings(ids) + return ids +} + +// previouslyManaged returns the apps this runtime installed on an earlier pass +// that the authority no longer wants. Tracking what management placed is what +// keeps an operator's hand-installed apps out of scope for removal. +func previouslyManaged(managed map[string]struct{}, wanted map[string]struct{}) []string { + stale := make([]string, 0, len(managed)) + for appID := range managed { + if _, keep := wanted[appID]; !keep { + stale = append(stale, appID) + } + } + sort.Strings(stale) + return stale +} + +// withinDirectory reports whether candidate sits inside parent. It is used to +// keep the staging root outside the supervisor's scan root, so the guard has +// to resist a relative path that climbs back in. +func withinDirectory(parent, candidate string) bool { + absParent, parentErr := filepath.Abs(parent) + absCandidate, candidateErr := filepath.Abs(candidate) + if parentErr != nil || candidateErr != nil { + return false + } + relative, err := filepath.Rel(absParent, absCandidate) + if err != nil { + return false + } + return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) +} diff --git a/internal/enterprisecontrol/fleet_apps_test.go b/internal/enterprisecontrol/fleet_apps_test.go new file mode 100644 index 00000000..b2936fd4 --- /dev/null +++ b/internal/enterprisecontrol/fleet_apps_test.go @@ -0,0 +1,471 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package enterprisecontrol + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" +) + +// fakeInstaller stands in for the pilotctl subprocess. It plants a manifest +// declaring whatever grants the test wants, which is the only thing the +// reconciler reads out of an installed bundle. +type fakeInstaller struct { + grants map[string][]authority.FleetAppGrant + version map[string]string + failFor map[string]bool + installLog []string + removeLog []string +} + +func newFakeInstaller() *fakeInstaller { + return &fakeInstaller{ + grants: map[string][]authority.FleetAppGrant{}, + version: map[string]string{}, + failFor: map[string]bool{}, + } +} + +func (installer *fakeInstaller) Install(_ context.Context, appID, version, root string) error { + installer.installLog = append(installer.installLog, appID+"@"+version+"->"+filepath.Base(root)) + if installer.failFor[appID] { + return fmt.Errorf("simulated install failure") + } + if planted, ok := installer.version[appID]; ok { + version = planted + } + directory := filepath.Join(root, appID) + if err := os.MkdirAll(directory, 0o700); err != nil { + return err + } + grants := make([]map[string]string, 0, len(installer.grants[appID])) + for _, grant := range installer.grants[appID] { + grants = append(grants, map[string]string{"cap": grant.Cap, "target": grant.Target}) + } + manifest := map[string]any{ + "manifest_version": 1, + "id": appID, + "app_version": version, + "name": "Test App", + "description": "A test app used by the reconciler unit tests.", + "binary": map[string]string{"runtime": "go", "path": "app", "sha256": "aa" + repeat("0", 62)}, + "grants": grants, + "store": map[string]string{"publisher": "ed25519:" + repeat("A", 44), "signature": repeat("B", 64)}, + } + encoded, err := json.Marshal(manifest) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(directory, "manifest.json"), encoded, 0o600); err != nil { + return err + } + return os.WriteFile(filepath.Join(directory, "app"), []byte("#!/bin/sh\n"), 0o700) +} + +func (installer *fakeInstaller) Remove(_ context.Context, appID, root string) error { + installer.removeLog = append(installer.removeLog, appID+"<-"+filepath.Base(root)) + return os.RemoveAll(filepath.Join(root, appID)) +} + +func repeat(s string, n int) string { + out := "" + for i := 0; i < n; i++ { + out += s + } + return out +} + +func newAppsTestRuntime(t *testing.T) (*Runtime, string, string, string) { + t.Helper() + base := t.TempDir() + stateRoot := filepath.Join(base, "state") + installRoot := filepath.Join(base, "apps") + stagingRoot := filepath.Join(base, "apps-pending") + for _, directory := range []string{stateRoot, installRoot, stagingRoot} { + if err := os.MkdirAll(directory, 0o700); err != nil { + t.Fatal(err) + } + } + runtime := &Runtime{ + tenantID: "tenant-a", rolloutAgentID: "agent-a", + fleetStateEnabled: true, fleetStateRoot: stateRoot, + appsEnabled: true, appsInstallRoot: installRoot, appsStagingRoot: stagingRoot, + appsInterval: 30 * time.Second, appsManaged: map[string]struct{}{}, + } + return runtime, stateRoot, installRoot, stagingRoot +} + +func writeDesired(t *testing.T, stateRoot string, specs ...authority.FleetAppSpec) { + t.Helper() + document := authority.FleetAppsDocument{ + Version: authority.FleetAppsVersion, TenantID: "tenant-a", AgentID: "agent-a", + Desired: specs, Reason: "unit test desired set", IssuedAt: time.Now().Unix(), + } + document.Normalize() + if err := document.Validate(); err != nil { + t.Fatalf("desired document invalid: %v", err) + } + encoded, err := json.MarshalIndent(document, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stateRoot, authority.FleetAppsDocumentPath), append(encoded, '\n'), 0o600); err != nil { + t.Fatal(err) + } +} + +func readObserved(t *testing.T, stateRoot string) map[string]authority.FleetAppState { + t.Helper() + raw, err := os.ReadFile(filepath.Join(stateRoot, authority.FleetAppsReportPath)) + if err != nil { + t.Fatalf("read observed report: %v", err) + } + var report authority.FleetAppsReport + if err := json.Unmarshal(raw, &report); err != nil { + t.Fatal(err) + } + if err := report.Validate(); err != nil { + t.Fatalf("observed report invalid: %v", err) + } + states := make(map[string]authority.FleetAppState, len(report.Apps)) + for _, state := range report.Apps { + states[state.ID] = state + } + return states +} + +// An app the tenant has not reviewed must land in staging, never in the +// supervisor's scan root. This is the whole grant boundary. +func TestReconcileHoldsUnreviewedAppOutOfInstallRoot(t *testing.T) { + runtime, stateRoot, installRoot, stagingRoot := newAppsTestRuntime(t) + installer := newFakeInstaller() + installer.grants["io.pilot.duckdb"] = []authority.FleetAppGrant{{Cap: "fs.read", Target: "$APP/*"}} + + writeDesired(t, stateRoot, authority.FleetAppSpec{ + ID: "io.pilot.duckdb", Version: "1.5.4", ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), + }) + + result, err := runtime.ReconcileApps(context.Background(), installer) + if err != nil { + t.Fatalf("reconcile: %v", err) + } + if result.Staged != 1 || result.Installed != 0 { + t.Fatalf("expected one staged and none installed, got %+v", result) + } + if _, err := os.Stat(filepath.Join(installRoot, "io.pilot.duckdb")); !os.IsNotExist(err) { + t.Fatal("unreviewed app reached the supervisor's install root") + } + if _, err := os.Stat(filepath.Join(stagingRoot, "io.pilot.duckdb")); err != nil { + t.Fatalf("expected staged copy: %v", err) + } + state := readObserved(t, stateRoot)["io.pilot.duckdb"] + if state.Status != authority.FleetAppGrantBlocked { + t.Fatalf("status = %q, want %q", state.Status, authority.FleetAppGrantBlocked) + } + // The report must carry the grants so the console can show them. + if len(state.DeclaredGrants) != 1 || state.DeclaredGrants[0].Cap != "fs.read" { + t.Fatalf("declared grants not reported: %+v", state.DeclaredGrants) + } +} + +// Once the desired document accepts exactly what the manifest declares, the +// app is promoted into the install root and the staged copy is cleaned up. +func TestReconcilePromotesAppOnceGrantsAccepted(t *testing.T) { + runtime, stateRoot, installRoot, stagingRoot := newAppsTestRuntime(t) + installer := newFakeInstaller() + grants := []authority.FleetAppGrant{{Cap: "fs.read", Target: "$APP/*"}, {Cap: "audit.log", Target: "*"}} + installer.grants["io.pilot.duckdb"] = grants + + writeDesired(t, stateRoot, authority.FleetAppSpec{ + ID: "io.pilot.duckdb", Version: "1.5.4", AcceptedGrants: grants, + ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), + }) + + result, err := runtime.ReconcileApps(context.Background(), installer) + if err != nil { + t.Fatalf("reconcile: %v", err) + } + if result.Installed != 1 || result.Staged != 0 { + t.Fatalf("expected one installed, got %+v", result) + } + if _, err := os.Stat(filepath.Join(installRoot, "io.pilot.duckdb")); err != nil { + t.Fatalf("accepted app missing from install root: %v", err) + } + if _, err := os.Stat(filepath.Join(stagingRoot, "io.pilot.duckdb")); !os.IsNotExist(err) { + t.Fatal("staged copy was not cleaned up after promotion") + } + if state := readObserved(t, stateRoot)["io.pilot.duckdb"]; state.Status != authority.FleetAppInstalled { + t.Fatalf("status = %q, want installed", state.Status) + } +} + +// A partial acceptance must not promote. Covering one of two declared grants +// is not covering the manifest. +func TestReconcileRefusesPartialGrantAcceptance(t *testing.T) { + runtime, stateRoot, installRoot, _ := newAppsTestRuntime(t) + installer := newFakeInstaller() + installer.grants["io.pilot.duckdb"] = []authority.FleetAppGrant{ + {Cap: "fs.read", Target: "$APP/*"}, + {Cap: "net.dial", Target: "api.example.com"}, + } + + writeDesired(t, stateRoot, authority.FleetAppSpec{ + ID: "io.pilot.duckdb", Version: "1.5.4", + AcceptedGrants: []authority.FleetAppGrant{{Cap: "fs.read", Target: "$APP/*"}}, + ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), + }) + + if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { + t.Fatalf("reconcile: %v", err) + } + if _, err := os.Stat(filepath.Join(installRoot, "io.pilot.duckdb")); !os.IsNotExist(err) { + t.Fatal("app with an uncovered grant was promoted") + } + if state := readObserved(t, stateRoot)["io.pilot.duckdb"]; state.Status != authority.FleetAppGrantBlocked { + t.Fatalf("status = %q, want grant_blocked", state.Status) + } +} + +// If a catalogue republish widens an app's grants, a node that already runs it +// must demote it rather than keep running the wider capability set. +func TestReconcileDemotesAppWhenGrantsWiden(t *testing.T) { + runtime, stateRoot, installRoot, stagingRoot := newAppsTestRuntime(t) + installer := newFakeInstaller() + original := []authority.FleetAppGrant{{Cap: "fs.read", Target: "$APP/*"}} + installer.grants["io.pilot.duckdb"] = original + + spec := authority.FleetAppSpec{ + ID: "io.pilot.duckdb", Version: "1.5.4", AcceptedGrants: original, + ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), + } + writeDesired(t, stateRoot, spec) + if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { + t.Fatalf("first reconcile: %v", err) + } + if _, err := os.Stat(filepath.Join(installRoot, "io.pilot.duckdb")); err != nil { + t.Fatalf("expected app installed after first pass: %v", err) + } + + // The app is republished asking for more than was accepted. + installer.grants["io.pilot.duckdb"] = append(original, authority.FleetAppGrant{Cap: "proc.exec", Target: "/bin/sh"}) + if err := installer.Install(context.Background(), "io.pilot.duckdb", "1.5.4", installRoot); err != nil { + t.Fatal(err) + } + + if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { + t.Fatalf("second reconcile: %v", err) + } + if _, err := os.Stat(filepath.Join(installRoot, "io.pilot.duckdb")); !os.IsNotExist(err) { + t.Fatal("app with widened grants stayed in the install root") + } + if _, err := os.Stat(filepath.Join(stagingRoot, "io.pilot.duckdb")); err != nil { + t.Fatalf("demoted app should be staged for review: %v", err) + } + if state := readObserved(t, stateRoot)["io.pilot.duckdb"]; state.Status != authority.FleetAppGrantBlocked { + t.Fatalf("status = %q, want grant_blocked", state.Status) + } +} + +// Dropping an app from the desired set withdraws it from the node. +func TestReconcileRemovesAppDroppedFromDesiredSet(t *testing.T) { + runtime, stateRoot, installRoot, stagingRoot := newAppsTestRuntime(t) + installer := newFakeInstaller() + grants := []authority.FleetAppGrant{{Cap: "audit.log", Target: "*"}} + installer.grants["io.pilot.duckdb"] = grants + + writeDesired(t, stateRoot, authority.FleetAppSpec{ + ID: "io.pilot.duckdb", Version: "1.5.4", AcceptedGrants: grants, + ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), + }) + if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { + t.Fatalf("install pass: %v", err) + } + + writeDesired(t, stateRoot) + result, err := runtime.ReconcileApps(context.Background(), installer) + if err != nil { + t.Fatalf("removal pass: %v", err) + } + if result.Removed != 1 { + t.Fatalf("expected one removal, got %+v", result) + } + for _, root := range []string{installRoot, stagingRoot} { + if _, err := os.Stat(filepath.Join(root, "io.pilot.duckdb")); !os.IsNotExist(err) { + t.Fatalf("app still present under %s", root) + } + } +} + +// An app a local operator installed by hand is not management's to remove. +func TestReconcileLeavesUnmanagedAppsAlone(t *testing.T) { + runtime, stateRoot, installRoot, _ := newAppsTestRuntime(t) + installer := newFakeInstaller() + if err := installer.Install(context.Background(), "io.pilot.handrolled", "0.1.0", installRoot); err != nil { + t.Fatal(err) + } + writeDesired(t, stateRoot) + + if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { + t.Fatalf("reconcile: %v", err) + } + if _, err := os.Stat(filepath.Join(installRoot, "io.pilot.handrolled")); err != nil { + t.Fatalf("hand-installed app was removed by management: %v", err) + } +} + +// A desired document addressed to another node must be refused even though it +// arrived through a signed channel — the file also sits on local disk. +func TestReconcileRefusesDocumentForAnotherNode(t *testing.T) { + runtime, stateRoot, _, _ := newAppsTestRuntime(t) + document := authority.FleetAppsDocument{ + Version: authority.FleetAppsVersion, TenantID: "tenant-a", AgentID: "agent-elsewhere", + Reason: "document copied from another machine", IssuedAt: time.Now().Unix(), + } + encoded, err := json.MarshalIndent(document, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stateRoot, authority.FleetAppsDocumentPath), encoded, 0o600); err != nil { + t.Fatal(err) + } + if _, err := runtime.ReconcileApps(context.Background(), newFakeInstaller()); err == nil { + t.Fatal("expected a document addressed to another node to be refused") + } +} + +// A missing desired document means "manage no apps here", not an error. +func TestReconcileTreatsMissingDocumentAsEmptyDesiredSet(t *testing.T) { + runtime, stateRoot, _, _ := newAppsTestRuntime(t) + result, err := runtime.ReconcileApps(context.Background(), newFakeInstaller()) + if err != nil { + t.Fatalf("reconcile: %v", err) + } + if result.Desired != 0 { + t.Fatalf("expected empty desired set, got %+v", result) + } + if _, err := os.Stat(filepath.Join(stateRoot, authority.FleetAppsReportPath)); err != nil { + t.Fatalf("an empty reconcile must still publish an inventory: %v", err) + } +} + +// A failing install is reported, not fatal, and must not leave the app in the +// install root. +func TestReconcileReportsInstallFailure(t *testing.T) { + runtime, stateRoot, installRoot, _ := newAppsTestRuntime(t) + installer := newFakeInstaller() + installer.failFor["io.pilot.duckdb"] = true + + writeDesired(t, stateRoot, authority.FleetAppSpec{ + ID: "io.pilot.duckdb", Version: "1.5.4", ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), + }) + result, err := runtime.ReconcileApps(context.Background(), installer) + if err != nil { + t.Fatalf("a failing app must not fail the whole reconcile: %v", err) + } + if result.Failed != 1 { + t.Fatalf("expected one failure, got %+v", result) + } + if _, err := os.Stat(filepath.Join(installRoot, "io.pilot.duckdb")); !os.IsNotExist(err) { + t.Fatal("failed install left an app in the install root") + } + if state := readObserved(t, stateRoot)["io.pilot.duckdb"]; state.Status != authority.FleetAppFailed { + t.Fatalf("status = %q, want failed", state.Status) + } +} + +func TestWithinDirectoryRejectsNestedStagingRoot(t *testing.T) { + if !withinDirectory("/var/pilot/apps", "/var/pilot/apps/pending") { + t.Fatal("nested staging root should be detected") + } + if withinDirectory("/var/pilot/apps", "/var/pilot/apps-pending") { + t.Fatal("sibling staging root must not be treated as nested") + } + if withinDirectory("/var/pilot/apps", "/var/pilot") { + t.Fatal("parent directory must not be treated as nested") + } +} + +// The inventory must not be rewritten when nothing changed. Every rewrite +// bumps the state revision the console fences its mutations on, so a ticking +// timestamp would invalidate an operator's in-flight install. +func TestReconcileDoesNotRewriteUnchangedInventory(t *testing.T) { + runtime, stateRoot, _, _ := newAppsTestRuntime(t) + installer := newFakeInstaller() + grants := []authority.FleetAppGrant{{Cap: "audit.log", Target: "*"}} + installer.grants["io.pilot.duckdb"] = grants + writeDesired(t, stateRoot, authority.FleetAppSpec{ + ID: "io.pilot.duckdb", Version: "1.5.4", AcceptedGrants: grants, + ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), + }) + + if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { + t.Fatalf("first reconcile: %v", err) + } + reportPath := filepath.Join(stateRoot, authority.FleetAppsReportPath) + first, err := os.ReadFile(reportPath) + if err != nil { + t.Fatal(err) + } + + // A later pass with identical results must leave the bytes untouched even + // though wall-clock time has advanced. + time.Sleep(1100 * time.Millisecond) + if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { + t.Fatalf("second reconcile: %v", err) + } + second, err := os.ReadFile(reportPath) + if err != nil { + t.Fatal(err) + } + if string(first) != string(second) { + t.Fatal("unchanged inventory was rewritten, which would churn the state revision") + } +} + +// A real change must still be published. +func TestReconcileRepublishesInventoryOnChange(t *testing.T) { + runtime, stateRoot, _, _ := newAppsTestRuntime(t) + installer := newFakeInstaller() + installer.grants["io.pilot.duckdb"] = []authority.FleetAppGrant{{Cap: "audit.log", Target: "*"}} + writeDesired(t, stateRoot, authority.FleetAppSpec{ + ID: "io.pilot.duckdb", Version: "1.5.4", ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), + }) + if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { + t.Fatalf("first reconcile: %v", err) + } + if status := readObserved(t, stateRoot)["io.pilot.duckdb"].Status; status != authority.FleetAppGrantBlocked { + t.Fatalf("expected grant_blocked, got %q", status) + } + + // Accept the grants; the inventory must now report installed. + writeDesired(t, stateRoot, authority.FleetAppSpec{ + ID: "io.pilot.duckdb", Version: "1.5.4", + AcceptedGrants: []authority.FleetAppGrant{{Cap: "audit.log", Target: "*"}}, + ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), + }) + if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { + t.Fatalf("second reconcile: %v", err) + } + if status := readObserved(t, stateRoot)["io.pilot.duckdb"].Status; status != authority.FleetAppInstalled { + t.Fatalf("expected installed after acceptance, got %q", status) + } +} + +// Cross-repo invariant: the console writes these paths through the state +// mutation channel, so the node's own protected-path guard must not refuse +// them. If someone renames a document to include "policy" or "trust", installs +// silently stop working. +func TestAppDocumentPathsAreNotProtected(t *testing.T) { + for _, path := range []string{authority.FleetAppsDocumentPath, authority.FleetAppsReportPath} { + if fleetMutationPathProtected(path) { + t.Fatalf("%q is refused by the node's protected-path guard", path) + } + } +} diff --git a/internal/managedsdk/authority/fleet_apps.go b/internal/managedsdk/authority/fleet_apps.go new file mode 100644 index 00000000..56676a54 --- /dev/null +++ b/internal/managedsdk/authority/fleet_apps.go @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package authority + +import ( + "fmt" + "regexp" + "sort" + "strings" + "unicode/utf8" +) + +const ( + FleetAppsVersion uint16 = 1 + + // FleetAppsDocumentPath is the desired-app-set document's path relative to + // the node's fleet state root. It is delivered by an ordinary signed + // FleetStateMutation: apps deliberately introduce no new wire protocol and + // no new command vocabulary, so a node that already accepts state + // mutations needs no protocol upgrade to accept apps. + FleetAppsDocumentPath = "apps.json" + + MaxFleetAppsEntries = 64 + MaxFleetAppGrants = 64 + MaxFleetAppReasonSize = 256 +) + +// fleetAppIDPattern mirrors app-store/pkg/manifest idPattern. The authority +// refuses to distribute an identifier the node's manifest validator would +// later reject, so an operator learns at approval time rather than at the +// node's next reconcile. +var fleetAppIDPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9_-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9_-]*[a-z0-9])?)+$`) + +// fleetAppVersionPattern mirrors the manifest's simplified semver. +var fleetAppVersionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$`) + +// FleetAppGrant is one manifest-declared capability that a tenant +// administrator accepted on the fleet's behalf. It carries no authority of its +// own: the node re-reads the installed manifest and refuses to start an app +// whose declared grants are not covered by this accepted set, so a catalogue +// that later widens an app's grants fails closed instead of silently gaining +// capability across the fleet. +type FleetAppGrant struct { + Cap string `json:"cap"` + Target string `json:"target"` +} + +func (grant FleetAppGrant) Validate() error { + if !boundedFleetText(grant.Cap, 64, false) || !boundedFleetText(grant.Target, 512, true) { + return fmt.Errorf("authority: invalid fleet app grant") + } + return nil +} + +// FleetAppSpec is one desired app. Version pins what the node resolves out of +// the publisher-signed catalogue; the bundle's per-platform sha256 stays in +// that catalogue rather than here, because one desired-state document is +// distributed unchanged to a mixed-platform fleet. +type FleetAppSpec struct { + ID string `json:"id"` + Version string `json:"version"` + AcceptedGrants []FleetAppGrant `json:"accepted_grants"` + ApprovedBy string `json:"approved_by"` + ApprovedAt int64 `json:"approved_at"` +} + +func (spec FleetAppSpec) Validate() error { + if !fleetAppIDPattern.MatchString(spec.ID) || len(spec.ID) > 128 { + return fmt.Errorf("authority: invalid fleet app id") + } + if !fleetAppVersionPattern.MatchString(spec.Version) { + return fmt.Errorf("authority: invalid fleet app version for %s", spec.ID) + } + if err := validateIdentifier("fleet app approver", spec.ApprovedBy); err != nil { + return err + } + if spec.ApprovedAt <= 0 { + return fmt.Errorf("authority: fleet app %s carries no approval time", spec.ID) + } + if len(spec.AcceptedGrants) > MaxFleetAppGrants { + return fmt.Errorf("authority: fleet app %s declares too many accepted grants", spec.ID) + } + seen := make(map[string]struct{}, len(spec.AcceptedGrants)) + for _, grant := range spec.AcceptedGrants { + if err := grant.Validate(); err != nil { + return err + } + key := grant.Cap + "\x00" + grant.Target + if _, exists := seen[key]; exists { + return fmt.Errorf("authority: fleet app %s repeats an accepted grant", spec.ID) + } + seen[key] = struct{}{} + } + return nil +} + +// FleetAppsDocument is the complete desired app set for one node. It is +// declarative on purpose: the node reconciles toward it and is free to be +// offline, restarted, or rebuilt from an empty disk in between. A container +// fleet member whose filesystem is discarded on restart converges to the same +// set without the authority replaying anything. +type FleetAppsDocument struct { + Version uint16 `json:"version"` + TenantID string `json:"tenant_id"` + AgentID string `json:"agent_id"` + Desired []FleetAppSpec `json:"desired"` + Reason string `json:"reason"` + IssuedAt int64 `json:"issued_at"` +} + +func (document FleetAppsDocument) Validate() error { + if document.Version != FleetAppsVersion || document.IssuedAt <= 0 { + return fmt.Errorf("authority: invalid fleet apps document") + } + for name, value := range map[string]string{"tenant_id": document.TenantID, "agent_id": document.AgentID} { + if err := validateIdentifier(name, value); err != nil { + return err + } + } + if !boundedFleetText(document.Reason, MaxFleetAppReasonSize, false) || len(strings.TrimSpace(document.Reason)) < 8 { + return fmt.Errorf("authority: invalid fleet apps document reason") + } + if len(document.Desired) > MaxFleetAppsEntries { + return fmt.Errorf("authority: fleet apps document exceeds %d entries", MaxFleetAppsEntries) + } + seen := make(map[string]struct{}, len(document.Desired)) + for _, spec := range document.Desired { + if err := spec.Validate(); err != nil { + return err + } + if _, exists := seen[spec.ID]; exists { + return fmt.Errorf("authority: fleet apps document repeats %s", spec.ID) + } + seen[spec.ID] = struct{}{} + } + return nil +} + +// Normalize orders the desired set so that re-approving an unchanged fleet +// produces a byte-identical document. Without this the console would queue a +// mutation, and the node would report a new revision, every time an operator +// opened the page and pressed save with nothing changed. +func (document *FleetAppsDocument) Normalize() { + sort.Slice(document.Desired, func(i, j int) bool { return document.Desired[i].ID < document.Desired[j].ID }) + for index := range document.Desired { + grants := document.Desired[index].AcceptedGrants + sort.Slice(grants, func(i, j int) bool { + if grants[i].Cap != grants[j].Cap { + return grants[i].Cap < grants[j].Cap + } + return grants[i].Target < grants[j].Target + }) + } +} + +// FleetAppState is one app as the node actually found it, reported back +// through the ordinary fleet state mirror rather than a new channel. +// +// DeclaredGrants is what the installed bundle's manifest actually asks for. +// The catalogue does not publish grants — they exist only inside the signed +// bundle — so the fleet is the only truthful source for them. A node that +// installs an app the tenant has not yet reviewed reports the grants here and +// holds the app unstarted, which lets the console show an operator the real +// capability list before anyone accepts it. +type FleetAppState struct { + ID string `json:"id"` + Version string `json:"version"` + Status string `json:"status"` + Detail string `json:"detail,omitempty"` + BinarySHA256 string `json:"binary_sha256,omitempty"` + DeclaredGrants []FleetAppGrant `json:"declared_grants,omitempty"` + ObservedAt int64 `json:"observed_at"` +} + +const ( + FleetAppInstalled = "installed" + FleetAppPending = "pending" + FleetAppFailed = "failed" + FleetAppGrantBlocked = "grant_blocked" +) + +func (state FleetAppState) Validate() error { + if !fleetAppIDPattern.MatchString(state.ID) || state.ObservedAt <= 0 { + return fmt.Errorf("authority: invalid fleet app state") + } + switch state.Status { + case FleetAppInstalled, FleetAppPending, FleetAppFailed, FleetAppGrantBlocked: + default: + return fmt.Errorf("authority: invalid fleet app status for %s", state.ID) + } + if state.Version != "" && !fleetAppVersionPattern.MatchString(state.Version) { + return fmt.Errorf("authority: invalid fleet app state version for %s", state.ID) + } + if state.BinarySHA256 != "" && !lowerHexIdentifier(state.BinarySHA256, 64) { + return fmt.Errorf("authority: invalid fleet app binary digest for %s", state.ID) + } + if !boundedFleetText(state.Detail, 512, true) || !utf8.ValidString(state.Detail) { + return fmt.Errorf("authority: invalid fleet app state detail for %s", state.ID) + } + if len(state.DeclaredGrants) > MaxFleetAppGrants { + return fmt.Errorf("authority: fleet app %s reports too many declared grants", state.ID) + } + for _, grant := range state.DeclaredGrants { + if err := grant.Validate(); err != nil { + return err + } + } + return nil +} + +// FleetAppsReport is the node-authored inventory document. The node writes it +// into its own state tree, so it arrives through the existing signed snapshot +// and needs no separate endpoint, storage, or retention policy. +type FleetAppsReport struct { + Version uint16 `json:"version"` + TenantID string `json:"tenant_id"` + AgentID string `json:"agent_id"` + Apps []FleetAppState `json:"apps"` + ObservedAt int64 `json:"observed_at"` +} + +// FleetAppsReportPath is where the node publishes its inventory. It is +// deliberately distinct from FleetAppsDocumentPath: the authority owns the +// desired set and the node owns the observed set, so neither overwrites the +// other and drift between them is visible rather than resolved silently. +const FleetAppsReportPath = "apps-observed.json" + +func (report FleetAppsReport) Validate() error { + if report.Version != FleetAppsVersion || report.ObservedAt <= 0 { + return fmt.Errorf("authority: invalid fleet apps report") + } + for name, value := range map[string]string{"tenant_id": report.TenantID, "agent_id": report.AgentID} { + if err := validateIdentifier(name, value); err != nil { + return err + } + } + if len(report.Apps) > MaxFleetAppsEntries { + return fmt.Errorf("authority: fleet apps report exceeds %d entries", MaxFleetAppsEntries) + } + for _, state := range report.Apps { + if err := state.Validate(); err != nil { + return err + } + } + return nil +} + +// GrantsCovered reports whether every grant an installed manifest declares is +// covered by what the administrator accepted. The node calls this before it +// lets the supervisor start an app; the authority calls it to show drift in +// the console. Both must agree, so the comparison lives here rather than in +// either caller. +func GrantsCovered(declared, accepted []FleetAppGrant) bool { + allowed := make(map[string]struct{}, len(accepted)) + for _, grant := range accepted { + allowed[grant.Cap+"\x00"+grant.Target] = struct{}{} + } + for _, grant := range declared { + if _, ok := allowed[grant.Cap+"\x00"+grant.Target]; !ok { + return false + } + } + return true +} From 3802cfa1da35ddd02a4552903d2f840e15bd730d Mon Sep 17 00:00:00 2001 From: Alexgodoroja Date: Thu, 27 Aug 2026 10:45:54 -0700 Subject: [PATCH 2/4] feat(apps): emit the apps control block and honour the approved version Two halves of the same gap, both found preparing the App Store e2e. Adoption never wrote an apps block, so HasAppReconcile() was false on every managed node and the reconciler this repo ships could not run. pilotctl adopt now emits one when the authority grants the option, gated on fleet state because the desired document and the observed report both travel that way. The install and staging roots are left to the runtime defaults so the sibling rule -- staging must sit outside the root the supervisor scans, or an app could be started before its grants are accepted -- stays enforced in one place. The reconciler also took a version argument it never used: it ran `pilotctl appstore install --force`, which installs whatever the catalogue currently carries. The console pins the version the operator approved, so once the catalogue moved ahead the node would install software nobody approved, and reconcileOneApp's version comparison would never converge -- re-installing on every tick, every 30 seconds, indefinitely. install now takes --version and fails closed with version_unavailable when the catalogue cannot satisfy the pin, because the catalogue carries only each app's current release. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 + cmd/pilotctl/appstore.go | 21 +++- cmd/pilotctl/appstore_catalogue.go | 31 ++++++ cmd/pilotctl/enterprise_adopt.go | 16 ++++ internal/enterprisecontrol/fleet_apps.go | 10 +- .../enterprisecontrol/fleet_apps_gate_test.go | 96 +++++++++++++++++++ internal/managedsdk/authorityhttp/types.go | 1 + 7 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 internal/enterprisecontrol/fleet_apps_gate_test.go diff --git a/.gitignore b/.gitignore index 978001a7..fff0558a 100644 --- a/.gitignore +++ b/.gitignore @@ -193,3 +193,4 @@ tests/integration/local/logs/ tests/integration/local/.runs/ tests/integration/logs-run.out tests/integration/logs-run*.out +.gstack/ diff --git a/cmd/pilotctl/appstore.go b/cmd/pilotctl/appstore.go index 6010d305..95189431 100644 --- a/cmd/pilotctl/appstore.go +++ b/cmd/pilotctl/appstore.go @@ -1037,16 +1037,23 @@ func resolveUnder(base, rel string) (string, error) { func cmdAppStoreInstall(args []string) { if len(args) < 1 { fatalHint("invalid_argument", - "usage: pilotctl appstore install [--force] [--local]", + "usage: pilotctl appstore install [--force] [--local] [--version ]", "missing app id or bundle dir") } target := args[0] force := false + wantVersion := "" allowLocal := false for i := 1; i < len(args); i++ { switch args[i] { case "--force", "-f": force = true + case "--version": + if i+1 >= len(args) { + fatalHint("invalid_argument", "usage: --version ", "--version needs a value") + } + i++ + wantVersion = args[i] case "--local": // Required acknowledgement when installing from a local // directory. Catalogue installs ignore this; path installs @@ -1056,7 +1063,7 @@ func cmdAppStoreInstall(args []string) { allowLocal = true default: fatalHint("invalid_argument", - "available flags: --force, --local", + "available flags: --force, --local, --version", "unknown install flag: %s", args[i]) } } @@ -1065,8 +1072,16 @@ func cmdAppStoreInstall(args []string) { // Catalogue path = signed, runs the standard signature gate. // Local path = sideload, requires --local AND must satisfy the // sideload allow-list before the supervisor will load it. - bundleDir, source, err := resolveInstallTarget(target) + bundleDir, source, err := resolveInstallTargetVersion(target, wantVersion) if err != nil { + if errors.Is(err, ErrCatalogueVersionUnavailable) { + // Distinct from a bad argument: the caller asked for a version the + // catalogue no longer carries. Installing whatever is current + // instead would hand a node software nobody approved. + fatalHint("version_unavailable", + "the catalogue carries only each app's current release; re-approve the app to move the pinned version forward", + "%v", err) + } fatalHint("invalid_argument", "the argument must be either a catalogue ID (`pilotctl appstore catalogue` to list) or a path to a bundle dir containing manifest.json", "%v", err) diff --git a/cmd/pilotctl/appstore_catalogue.go b/cmd/pilotctl/appstore_catalogue.go index 9f43bbf1..9e68ccf9 100644 --- a/cmd/pilotctl/appstore_catalogue.go +++ b/cmd/pilotctl/appstore_catalogue.go @@ -348,6 +348,37 @@ const ( // `target` matches a catalogue ID, the catalogue entry is fetched, // verified, and unpacked. Otherwise `target` is treated as a local // path and the caller is expected to apply sideload policy. +// ErrCatalogueVersionUnavailable means the catalogue no longer offers the +// version the caller pinned. The catalogue carries only the current release of +// each app, so a pin that has moved on cannot be satisfied — and installing the +// version that happens to be current instead would silently give a node +// software its operator never approved. +var ErrCatalogueVersionUnavailable = errors.New("catalogue does not offer the requested version") + +// resolveInstallTargetVersion resolves target, and when wantVersion is set it +// refuses any catalogue entry that does not match it exactly. +func resolveInstallTargetVersion(target, wantVersion string) (string, installSource, error) { + if strings.TrimSpace(wantVersion) == "" { + return resolveInstallTarget(target) + } + c, err := loadCatalogue() + if err == nil { + for _, e := range c.Apps { + if target != e.ID { + continue + } + if e.Version != wantVersion { + return "", installSourceCatalogue, fmt.Errorf("%w: %s offers %q, not %q", ErrCatalogueVersionUnavailable, e.ID, e.Version, wantVersion) + } + dir, fetchErr := fetchAndUnpackBundle(e) + return dir, installSourceCatalogue, fetchErr + } + } + // A pinned version only means anything against the signed catalogue; a + // local sideload has no version to check it against. + return "", installSourceLocal, fmt.Errorf("%w: %s is not in the catalogue", ErrCatalogueVersionUnavailable, target) +} + func resolveInstallTarget(target string) (string, installSource, error) { c, err := loadCatalogue() if err != nil { diff --git a/cmd/pilotctl/enterprise_adopt.go b/cmd/pilotctl/enterprise_adopt.go index 3fddaef7..86f1e258 100644 --- a/cmd/pilotctl/enterprise_adopt.go +++ b/cmd/pilotctl/enterprise_adopt.go @@ -300,6 +300,22 @@ func installEnrolledAttachment(outputDirectory string, claim authorityhttp.NodeE config.Fleet.StateDirectory = "state" } } + // App reconciliation converges the installed set toward the desired + // document the App Store writes into fleet state. It is gated on state + // sync because both the desired document and the observed report travel + // as fleet state; without it the node would accept the option and then + // silently never reconcile. + if claim.Options.Apps && claim.Options.FleetControl && claim.Options.StateSync { + config.Apps = &enterprisecontrol.AppsConfig{ + Enabled: true, + // Left to the runtime defaults: apps install under ~/.pilot/apps + // and stage under ~/.pilot/apps-pending, which the runtime already + // enforces as siblings so a staged app is never inside the root the + // supervisor scans and can never be started before its grants are + // accepted. + ReconcileIntervalSeconds: 30, + } + } controlPath := filepath.Join(stage, "enterprise-control.json") if err := writeAdoptionJSON(controlPath, config); err != nil { return "", err diff --git a/internal/enterprisecontrol/fleet_apps.go b/internal/enterprisecontrol/fleet_apps.go index 4b3f66b7..fe7036b3 100644 --- a/internal/enterprisecontrol/fleet_apps.go +++ b/internal/enterprisecontrol/fleet_apps.go @@ -80,7 +80,15 @@ func (installer PilotctlInstaller) Install(ctx context.Context, appID, version, defer cancel() // --force lets an install replace a wrong-version copy in place; the // catalogue signature and sha256 gates still run either way. - command := exec.CommandContext(ctx, installer.BinaryPath, "appstore", "install", appID, "--force") + // The pinned version is passed through so the node installs what the + // operator approved. pilotctl fails closed when the catalogue has moved + // on, which surfaces as version_unavailable rather than silently + // installing whatever release happens to be current. + arguments := []string{"appstore", "install", appID, "--force"} + if strings.TrimSpace(version) != "" { + arguments = append(arguments, "--version", version) + } + command := exec.CommandContext(ctx, installer.BinaryPath, arguments...) command.Env = append(os.Environ(), "PILOT_APPSTORE_ROOT="+root) output, err := command.CombinedOutput() if err != nil { diff --git a/internal/enterprisecontrol/fleet_apps_gate_test.go b/internal/enterprisecontrol/fleet_apps_gate_test.go new file mode 100644 index 00000000..84aacd5f --- /dev/null +++ b/internal/enterprisecontrol/fleet_apps_gate_test.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package enterprisecontrol + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" +) + +// HasAppReconcile is the gate that decides whether a managed node ever acts on +// the App Store's desired document. It shipped defaulting to false with nothing +// in the adoption path able to turn it on, so the console queued installs that +// no node would ever perform. These cases pin the contract adoption must +// satisfy, including the fleet-state dependency: both the desired document and +// the observed report travel as fleet state, so apps without it would accept +// the option and then silently never reconcile. +func TestHasAppReconcileRequiresAppsAndFleetState(t *testing.T) { + for _, testCase := range []struct { + name string + apps, fleetState bool + stateRoot string + want bool + }{ + {name: "apps and fleet state", apps: true, fleetState: true, stateRoot: "/tmp/state", want: true}, + {name: "apps without fleet state", apps: true, fleetState: false, stateRoot: "/tmp/state", want: false}, + {name: "apps with no state root", apps: true, fleetState: true, stateRoot: "", want: false}, + {name: "fleet state without apps", apps: false, fleetState: true, stateRoot: "/tmp/state", want: false}, + {name: "neither", apps: false, fleetState: false, stateRoot: "", want: false}, + } { + t.Run(testCase.name, func(t *testing.T) { + runtime := &Runtime{appsEnabled: testCase.apps, fleetStateEnabled: testCase.fleetState, fleetStateRoot: testCase.stateRoot} + if got := runtime.HasAppReconcile(); got != testCase.want { + t.Fatalf("HasAppReconcile()=%v want %v", got, testCase.want) + } + }) + } + // A nil runtime must not panic the daemon's startup check. + var absent *Runtime + if absent.HasAppReconcile() { + t.Fatal("nil runtime reported app reconcile") + } +} + +// An enabled reconciler with a zero interval would spin as fast as the loop +// allows, so the interval must always be positive once the gate is open. +func TestAppReconcileIntervalIsPositiveWhenEnabled(t *testing.T) { + runtime := &Runtime{appsEnabled: true, fleetStateEnabled: true, fleetStateRoot: "/tmp/state", appsInterval: 30_000_000_000} + if runtime.AppReconcileInterval() <= 0 { + t.Fatal("enabled reconciler reported a non-positive interval") + } + disabled := &Runtime{} + if disabled.AppReconcileInterval() != 0 { + t.Fatal("disabled reconciler reported an interval") + } +} + +// recordingInstaller captures the arguments the reconciler would run. +type recordingInstaller struct { + installs []string + fail error +} + +func (installer *recordingInstaller) Install(_ context.Context, appID, version, root string) error { + installer.installs = append(installer.installs, appID+"@"+version) + return installer.fail +} + +func (installer *recordingInstaller) Remove(context.Context, string, string) error { return nil } + +// The desired document pins the version the operator approved. It must reach +// the installer, or the node silently installs whatever the catalogue happens +// to carry at reconcile time -- software nobody approved. +func TestReconcilePassesTheApprovedVersionToTheInstaller(t *testing.T) { + installer := &recordingInstaller{fail: errors.New("install refused")} + runtime := &Runtime{appsEnabled: true, fleetStateEnabled: true, fleetStateRoot: t.TempDir()} + state := runtime.reconcileOneApp( + context.Background(), installer, + authority.FleetAppSpec{ID: "io.pilot.example", Version: "1.2.3"}, + filepath.Join(t.TempDir(), "apps"), filepath.Join(t.TempDir(), "pending"), + time.Now(), + ) + if len(installer.installs) == 0 { + t.Fatal("reconcile never attempted an install") + } + if installer.installs[0] != "io.pilot.example@1.2.3" { + t.Fatalf("installer received %q, losing the approved version pin", installer.installs[0]) + } + if state.Status != authority.FleetAppFailed { + t.Fatalf("a refused install must report failed, got %q", state.Status) + } +} diff --git a/internal/managedsdk/authorityhttp/types.go b/internal/managedsdk/authorityhttp/types.go index 477f896a..1cb0e6aa 100644 --- a/internal/managedsdk/authorityhttp/types.go +++ b/internal/managedsdk/authorityhttp/types.go @@ -48,6 +48,7 @@ type NodeEnrollmentOptions struct { ActionControl bool `json:"action_control"` FleetControl bool `json:"fleet_control"` StateSync bool `json:"state_sync"` + Apps bool `json:"apps"` } // NodeEnrollmentClaimResponse is the atomic one-time response consumed by From cede00422eb90542dfa64961d7b5c4fc1e4a0b01 Mon Sep 17 00:00:00 2001 From: Alexgodoroja Date: Thu, 27 Aug 2026 11:07:21 -0700 Subject: [PATCH 3/4] fix(apps): create the app roots a fresh node has never had Reconcile validated the install and staging roots but never created them. The supervisor creates the install root only when it starts with apps already present, and nothing creates the staging root at all, so a freshly adopted node failed on every tick: managed app reconcile failed: app staging root: stat ~/.pilot/apps-pending: no such file or directory No app could ever be installed on a new node. Found by running the real end-to-end install rather than by any unit test, because every existing test supplied roots that already existed. Co-Authored-By: Claude Opus 5 (1M context) --- internal/enterprisecontrol/fleet_apps.go | 21 ++++++++++- .../enterprisecontrol/fleet_apps_gate_test.go | 37 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/internal/enterprisecontrol/fleet_apps.go b/internal/enterprisecontrol/fleet_apps.go index fe7036b3..fb7d78e9 100644 --- a/internal/enterprisecontrol/fleet_apps.go +++ b/internal/enterprisecontrol/fleet_apps.go @@ -130,6 +130,18 @@ func boundedInstallerOutput(output []byte) string { // the desired document is delivered as a state mutation and then read from // disk, so reconciliation is correct even during a spell when the authority is // unreachable. Config load already refuses to enable apps without state sync. +// ensureSecureDirectory creates path when absent, then applies the same +// ownership and permission checks secureDirectory enforces. Creation is +// deliberately here rather than at adoption: the roots are runtime-owned, and a +// node whose apps directory is removed between reconciles must recover rather +// than wedge. +func ensureSecureDirectory(path string) error { + if err := os.MkdirAll(path, 0o700); err != nil { + return err + } + return secureDirectory(path) +} + func (runtime *Runtime) HasAppReconcile() bool { return runtime != nil && runtime.appsEnabled && runtime.fleetStateEnabled && runtime.fleetStateRoot != "" } @@ -170,10 +182,15 @@ func (runtime *Runtime) ReconcileApps(ctx context.Context, installer AppInstalle if err != nil { return AppReconcileResult{}, err } - if err := secureDirectory(installRoot); err != nil { + // Create both roots before validating them. Nothing else owns their + // creation: the supervisor makes the install root only when it starts with + // apps present, and the staging root has no other creator at all, so a + // freshly adopted node would fail this check on every tick forever. 0o700 + // is what secureDirectory then demands. + if err := ensureSecureDirectory(installRoot); err != nil { return AppReconcileResult{}, fmt.Errorf("enterprise control: app install root: %w", err) } - if err := secureDirectory(stagingRoot); err != nil { + if err := ensureSecureDirectory(stagingRoot); err != nil { return AppReconcileResult{}, fmt.Errorf("enterprise control: app staging root: %w", err) } diff --git a/internal/enterprisecontrol/fleet_apps_gate_test.go b/internal/enterprisecontrol/fleet_apps_gate_test.go index 84aacd5f..e51ba8a4 100644 --- a/internal/enterprisecontrol/fleet_apps_gate_test.go +++ b/internal/enterprisecontrol/fleet_apps_gate_test.go @@ -5,6 +5,7 @@ package enterprisecontrol import ( "context" "errors" + "os" "path/filepath" "testing" "time" @@ -94,3 +95,39 @@ func TestReconcilePassesTheApprovedVersionToTheInstaller(t *testing.T) { t.Fatalf("a refused install must report failed, got %q", state.Status) } } + +// A freshly adopted node has neither app root on disk: the supervisor creates +// the install root only when it starts with apps already present, and nothing +// creates the staging root at all. Reconcile therefore has to create both, or +// it fails on every tick forever and no app can ever be installed. Found by +// running the real end-to-end install against a newly adopted node. +func TestReconcileCreatesBothAppRootsOnAFreshNode(t *testing.T) { + home := t.TempDir() + installRoot := filepath.Join(home, ".pilot", "apps") + stagingRoot := filepath.Join(home, ".pilot", "apps-pending") + + for _, root := range []string{installRoot, stagingRoot} { + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatalf("precondition: %s already exists", root) + } + if err := ensureSecureDirectory(root); err != nil { + t.Fatalf("ensureSecureDirectory(%s): %v", root, err) + } + info, err := os.Stat(root) + if err != nil { + t.Fatalf("%s was not created: %v", root, err) + } + if !info.IsDir() { + t.Fatalf("%s is not a directory", root) + } + // secureDirectory rejects group- or world-writable roots, so creation + // must not hand back something it will then refuse. + if info.Mode().Perm()&0o022 != 0 { + t.Fatalf("%s created group/world writable: %v", root, info.Mode().Perm()) + } + // Idempotent: a second reconcile tick must not fail. + if err := ensureSecureDirectory(root); err != nil { + t.Fatalf("second call on %s failed: %v", root, err) + } + } +} From d995fc023ff81d112eb3b6faf53be6de456c955d Mon Sep 17 00:00:00 2001 From: Alexgodoroja Date: Thu, 27 Aug 2026 14:51:36 -0700 Subject: [PATCH 4/4] fix: clear the security gates on the app reconcile work Three gosec findings inside the changed lines, and one pre-existing toolchain advisory that blocks the same gate. The two G204 subprocess findings are annotated rather than restructured, because the inputs are already constrained where it matters: every argument originates in an authority-signed desired document that readDesiredApps runs through FleetAppsDocument.Validate before the reconciler sees it, which bounds the app id to fleetAppIDPattern and the version to fleetAppVersionPattern. Neither pattern admits a shell metacharacter or a leading dash, the binary is the pilotctl shipped beside the daemon, and CommandContext invokes no shell, so neither argument injection nor command substitution is reachable. The G602 finding was a real if narrow readability problem: the bound was checked against i+1 and the value then read from args[i] after an increment. The value is now read from the index the check actually covers. govulncheck fails on five standard-library advisories in go1.25.12 -- none introduced here, all fixed in go1.25.13, and the platform repo already moved to that patch. Bumping the pin clears the gate without touching a dependency. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/pilotctl/appstore.go | 5 ++++- go.mod | 2 +- internal/enterprisecontrol/fleet_apps.go | 8 ++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/cmd/pilotctl/appstore.go b/cmd/pilotctl/appstore.go index 95189431..807bef6e 100644 --- a/cmd/pilotctl/appstore.go +++ b/cmd/pilotctl/appstore.go @@ -1049,11 +1049,14 @@ func cmdAppStoreInstall(args []string) { case "--force", "-f": force = true case "--version": + // Read the value before advancing so the bound is checked against + // the index actually used. if i+1 >= len(args) { fatalHint("invalid_argument", "usage: --version ", "--version needs a value") + return } + wantVersion = args[i+1] i++ - wantVersion = args[i] case "--local": // Required acknowledgement when installing from a local // directory. Catalogue installs ignore this; path installs diff --git a/go.mod b/go.mod index 1fc6c6dc..19bdfcae 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/pilot-protocol/pilotprotocol -go 1.25.12 +go 1.25.13 require ( github.com/coder/websocket v1.8.15 diff --git a/internal/enterprisecontrol/fleet_apps.go b/internal/enterprisecontrol/fleet_apps.go index fb7d78e9..2865a06e 100644 --- a/internal/enterprisecontrol/fleet_apps.go +++ b/internal/enterprisecontrol/fleet_apps.go @@ -88,6 +88,12 @@ func (installer PilotctlInstaller) Install(ctx context.Context, appID, version, if strings.TrimSpace(version) != "" { arguments = append(arguments, "--version", version) } + // #nosec G204 -- BinaryPath is the pilotctl shipped beside this daemon, and + // every argument originates in an authority-signed desired document that + // readDesiredApps has already run through FleetAppsDocument.Validate. That + // bounds the app id to fleetAppIDPattern and the version to + // fleetAppVersionPattern, neither of which admits a shell metacharacter or + // a leading dash, and CommandContext invokes no shell. command := exec.CommandContext(ctx, installer.BinaryPath, arguments...) command.Env = append(os.Environ(), "PILOT_APPSTORE_ROOT="+root) output, err := command.CombinedOutput() @@ -104,6 +110,8 @@ func (installer PilotctlInstaller) Remove(ctx context.Context, appID, root strin } ctx, cancel := context.WithTimeout(ctx, installer.timeout()) defer cancel() + // #nosec G204 -- same provenance as Install: a signed, validated app id + // matching fleetAppIDPattern, passed to the adjacent pilotctl without a shell. command := exec.CommandContext(ctx, installer.BinaryPath, "appstore", "uninstall", appID, "--yes") command.Env = append(os.Environ(), "PILOT_APPSTORE_ROOT="+root) output, err := command.CombinedOutput()