Skip to content

Commit 8c63d20

Browse files
TeoSlayerteovl
andauthored
feat(appstore): pilotctl install --local for sideloaded apps (#240)
* feat(appstore): pilotctl install --local for sideloaded apps Pairs with pilot-protocol/app-store#15. Local-path installs now require an explicit --local flag and produce a `.sideloaded` marker in the install dir; the supervisor uses that marker to skip publisher-signature verification and enforce the sideload allow-list. Behaviour changes: - `pilotctl appstore install <path>` → refused - `pilotctl appstore install <path> --local` → sideload - `pilotctl appstore install <id>` (catalogue ID) → unchanged When --local is passed: - manifest.EnforceSideloadPolicy(m) runs before staging; any grant outside the allow-list refuses the install up-front so users get a clear error instead of a silent supervisor-skip after the next rescan - `.sideloaded` is planted in the staging dir as mode 0o400 BEFORE the atomic rename, so there is no window where the dir appears under InstallRoot without the marker - install output prints the honest "manifest gate, not OS sandbox" caveat so users know what `--local` does and does not protect `appstore list` surfaces a `[sideloaded]` badge on the version line and adds the `sideloaded` field to the JSON output. A small internal refactor: resolveInstallTarget now returns an installSource tag (catalogue|local) so the install command can branch on trust regime without re-doing catalogue lookup. Test updates: - validManifestJSON now uses fs.read $APP/data instead of /tmp/data so the helper-built bundles satisfy the sideload policy and can be used in both the catalogue and sideload test paths - install/duplicate/text-mode tests pass --local since they install from a local bundle directory - SIDELOADED warning text is asserted in the text-mode install test so removing the warning would surface as a regression Bumps the app-store dep to the feat/sideload-policy SHA. Will rebase the version pin once that PR merges and a tagged release lands. * docs(appstore): show --local in install help Splits the install command into two lines in the appstore help — catalogue install vs sideload — so users discover --local from `pilotctl appstore` rather than via the trial-and-error of a local-path install that gets refused. * fix(ci): cli-reference-check captures stderr and tolerates --help exit 2 Two pre-existing bugs were masking each other in scripts/gen-cli-reference.sh: 1. \`pilotctl --help\` writes its banner to stderr (so that the same text shows for \`pilotctl <bad-flag>\`). The script only redirected stdout, so the help block was always empty. 2. \`pilotctl --help\` exits with code 2 by Go's flag convention. Under \`set -euo pipefail\` that aborted the whole script before the diff step ran. Net effect: the workflow never modified docs/cli-reference.md, so the \`git diff --exit-code\` step trivially passed. Any PR that touches \`cmd/pilotctl/**\` triggered the gen step, hit the same exit-2 abort, and \"passed\" for the wrong reason. PR #237/#238/#239 (catalogue-only bumps) skipped the gate entirely because they don't match the path filter, but the run history on main shows the check has been failing silently on every code change since the workflow was added. Fix: - 2>&1 so stderr lands in the doc - `|| true` so a non-zero exit doesn't abort the script No content change to docs/cli-reference.md — the current committed version already matches what the fixed script produces. * deps: pin app-store to post-merge main SHA Bumps github.com/pilot-protocol/app-store from the feat/sideload-policy SHA to the post-merge main SHA (#15 squash-merged as 8852c785). No code changes — the manifest API + supervisor scan code that ships in v1.0.1-beta.1.0.20260609061942-8852c785a264 is byte-equivalent to what was on the feat branch. --------- Co-authored-by: Teodor Calin <teodor@vulturelabs.io>
1 parent 6b999f6 commit 8c63d20

6 files changed

Lines changed: 117 additions & 26 deletions

File tree

cmd/pilotctl/appstore.go

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,11 @@ Usage:
102102
pilotctl appstore uninstall <id> --yes remove an installed app from the install root
103103
pilotctl appstore verify <bundle-dir> sha256-check a pre-install bundle against its manifest
104104
pilotctl appstore catalogue list apps available for one-command install
105-
pilotctl appstore install <app-id-or-dir> [--force]
105+
pilotctl appstore install <app-id> [--force]
106106
install by catalogue ID (fetches + verifies + extracts)
107-
OR by local bundle directory (offline / dev path)
107+
pilotctl appstore install <bundle-dir> --local [--force]
108+
sideload a local bundle (sandbox: fs.read/fs.write
109+
under $APP, audit.log; no net, no key.sign, no hooks)
108110
pilotctl appstore gen-key <key-file> generate a fresh ed25519 publisher keypair; prints the public side
109111
pilotctl appstore sign --key <key-file> <manifest>
110112
sign (or re-sign) a manifest's store.signature so the supervisor accepts it
@@ -145,6 +147,7 @@ type appListEntry struct {
145147
BinaryPresent bool `json:"binary_present"`
146148
Suspended bool `json:"suspended,omitempty"`
147149
ManifestValid bool `json:"manifest_valid"`
150+
Sideloaded bool `json:"sideloaded,omitempty"`
148151
}
149152

150153
func cmdAppStoreList(_ []string) {
@@ -185,6 +188,7 @@ func cmdAppStoreList(_ []string) {
185188
// budget is spent. Detecting it from disk lets list report
186189
// "suspended" without daemon IPC.
187190
_, suspErr := os.Stat(filepath.Join(dir, ".suspended"))
191+
_, sideErr := os.Stat(filepath.Join(dir, manifest.SideloadMarkerName))
188192
// Run the manifest's semantic Validate too — an app the
189193
// supervisor will silently skip should be VISIBLY broken in
190194
// list output, not look like a normal "stopped" app.
@@ -199,6 +203,7 @@ func cmdAppStoreList(_ []string) {
199203
BinaryPresent: binErr == nil,
200204
Suspended: suspErr == nil,
201205
ManifestValid: validationOK,
206+
Sideloaded: sideErr == nil,
202207
})
203208
}
204209
sort.Slice(apps, func(i, j int) bool { return apps[i].ID < apps[j].ID })
@@ -226,7 +231,11 @@ func cmdAppStoreList(_ []string) {
226231
state = "ready"
227232
}
228233
fmt.Printf(" %s\n", a.ID)
229-
fmt.Printf(" version: %s (manifest v%d, %s)\n", a.AppVersion, a.ManifestVersion, a.Protection)
234+
versionLine := fmt.Sprintf("%s (manifest v%d, %s)", a.AppVersion, a.ManifestVersion, a.Protection)
235+
if a.Sideloaded {
236+
versionLine += " [sideloaded]"
237+
}
238+
fmt.Printf(" version: %s\n", versionLine)
230239
fmt.Printf(" state: %s\n", state)
231240
if len(a.Methods) > 0 {
232241
fmt.Printf(" methods: %v\n", a.Methods)
@@ -971,32 +980,45 @@ type installReport struct {
971980
func cmdAppStoreInstall(args []string) {
972981
if len(args) < 1 {
973982
fatalHint("invalid_argument",
974-
"usage: pilotctl appstore install <app-id-or-dir> [--force]",
983+
"usage: pilotctl appstore install <app-id-or-dir> [--force] [--local]",
975984
"missing app id or bundle dir")
976985
}
977986
target := args[0]
978987
force := false
988+
allowLocal := false
979989
for i := 1; i < len(args); i++ {
980990
switch args[i] {
981991
case "--force", "-f":
982992
force = true
993+
case "--local":
994+
// Required acknowledgement when installing from a local
995+
// directory. Catalogue installs ignore this; path installs
996+
// without --local fail closed so a typo'd app id (which
997+
// happens to also exist as a directory in the cwd) can't
998+
// silently sideload an unsigned bundle.
999+
allowLocal = true
9831000
default:
9841001
fatalHint("invalid_argument",
985-
"available flags: --force",
1002+
"available flags: --force, --local",
9861003
"unknown install flag: %s", args[i])
9871004
}
9881005
}
9891006

990-
// Resolve `target` to a local bundle dir. If it's a catalogue ID
991-
// (e.g. "io.pilot.wallet"), fetch + verify the tarball and unpack
992-
// into a tempdir. If it's already a directory, use it as-is. The
993-
// rest of this function operates only on the directory.
994-
bundleDir, err := resolveInstallTarget(target)
1007+
// Resolve `target` to a local bundle dir and a source tag.
1008+
// Catalogue path = signed, runs the standard signature gate.
1009+
// Local path = sideload, requires --local AND must satisfy the
1010+
// sideload allow-list before the supervisor will load it.
1011+
bundleDir, source, err := resolveInstallTarget(target)
9951012
if err != nil {
9961013
fatalHint("invalid_argument",
9971014
"the argument must be either a catalogue ID (`pilotctl appstore catalogue` to list) or a path to a bundle dir containing manifest.json",
9981015
"%v", err)
9991016
}
1017+
if source == installSourceLocal && !allowLocal {
1018+
fatalHint("invalid_argument",
1019+
"local sideloads carry no catalogue signature; pass --local to confirm you trust this bundle's source. The supervisor will clamp the manifest to a small allow-list (fs.read/fs.write under $APP, audit.log). No net.dial, key.sign, ipc.call to other apps, or daemon hooks.",
1020+
"refusing to install local path %q without --local", target)
1021+
}
10001022

10011023
// 1. Validate the bundle — same shape as verify. Reusing the
10021024
// surface manifest.Parse + sha256File makes the trust check
@@ -1026,6 +1048,18 @@ func cmdAppStoreInstall(args []string) {
10261048
"refusing to install a manifest the supervisor will reject; fix the listed issues and re-run",
10271049
"manifest validation: %s", validationErrSummary(errs))
10281050
}
1051+
if source == installSourceLocal {
1052+
// Same allow-list the supervisor enforces at scan time. We
1053+
// check it here too so users find out at install time, not at
1054+
// the next supervisor poll, what they need to strip from the
1055+
// manifest. Failing closed: no marker is planted unless the
1056+
// manifest passes.
1057+
if err := manifest.EnforceSideloadPolicy(m); err != nil {
1058+
fatalHint("invalid_argument",
1059+
"sideloaded apps may only declare audit.log, fs.read $APP/*, fs.write $APP/*. Remove the offending grant or use the catalogue install path for a reviewed app.",
1060+
"%v", err)
1061+
}
1062+
}
10291063
srcBin := filepath.Join(bundleDir, m.Binary.Path)
10301064
if _, err := os.Stat(srcBin); err != nil {
10311065
fatalHint("invalid_argument",
@@ -1092,6 +1126,21 @@ func cmdAppStoreInstall(args []string) {
10921126
"staged binary sha256 mismatch: manifest=%s staged=%s", m.Binary.SHA256, got)
10931127
}
10941128

1129+
if source == installSourceLocal {
1130+
// Plant the sentinel before the atomic rename so the moment
1131+
// the dir appears under InstallRoot it's already tagged
1132+
// sideloaded — there's no window where the supervisor could
1133+
// scan a sideloaded dir and treat it as catalogue-trusted.
1134+
// 0o400: read-only to the owning user; the file's mere
1135+
// existence is the signal, no content needed.
1136+
markerPath := filepath.Join(stagingDir, manifest.SideloadMarkerName)
1137+
if err := os.WriteFile(markerPath, nil, 0o400); err != nil {
1138+
_ = os.RemoveAll(stagingDir)
1139+
fatalHint("io_error", "check install root permissions",
1140+
"write sideload marker: %v", err)
1141+
}
1142+
}
1143+
10951144
// 4. Atomic swap. Rename of directories is atomic on Linux/macOS
10961145
// as long as the destination does not already exist; with
10971146
// --force we have to step aside the previous install first.
@@ -1162,6 +1211,11 @@ func cmdAppStoreInstall(args []string) {
11621211
}
11631212
fmt.Printf("installed %s v%s (manifest v%d) → %s\n",
11641213
report.AppID, report.AppVersion, report.ManifestVersion, report.InstalledTo)
1214+
if source == installSourceLocal {
1215+
fmt.Println("mode: SIDELOADED — manifest-level allow-list applied (audit.log, fs.read/$APP, fs.write/$APP).")
1216+
fmt.Println(" this is NOT an OS sandbox: a malicious binary that ignores its manifest can still")
1217+
fmt.Println(" misbehave at the syscall level. Only install paths from sources you trust.")
1218+
}
11651219
fmt.Println("note: the daemon rescans the install root periodically —")
11661220
fmt.Println(" this app will be picked up within ~30s (no daemon restart needed)")
11671221
}

cmd/pilotctl/appstore_catalogue.go

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,30 @@ func cmdAppStoreCatalogue(_ []string) {
133133
}
134134
}
135135

136+
// installSource tags how a bundle reached the install command.
137+
// The install path uses this to switch between catalogue-signed and
138+
// sideloaded trust regimes.
139+
type installSource int
140+
141+
const (
142+
// installSourceCatalogue: target matched a catalogue entry; the
143+
// bundle was downloaded and sha-verified against the catalogue
144+
// pin. Goes through the standard signed-manifest install.
145+
installSourceCatalogue installSource = iota
146+
// installSourceLocal: target was a local directory path. No
147+
// publisher signature is expected; the install command applies
148+
// the sideload allow-list policy and plants `.sideloaded` so the
149+
// supervisor uses the sideloaded trust regime at runtime.
150+
installSourceLocal
151+
)
152+
136153
// resolveInstallTarget turns the user's `target` arg into a local
137-
// bundle directory the existing install code can consume. If `target`
138-
// matches a catalogue ID, the catalogue entry is fetched, verified,
139-
// and unpacked. Otherwise `target` is treated as a local path.
140-
func resolveInstallTarget(target string) (string, error) {
154+
// bundle directory the existing install code can consume, plus a
155+
// source tag indicating which trust regime the bundle came from. If
156+
// `target` matches a catalogue ID, the catalogue entry is fetched,
157+
// verified, and unpacked. Otherwise `target` is treated as a local
158+
// path and the caller is expected to apply sideload policy.
159+
func resolveInstallTarget(target string) (string, installSource, error) {
141160
c, err := loadCatalogue()
142161
if err != nil {
143162
// Catalogue-lookup failure doesn't preclude a local-dir install
@@ -148,15 +167,16 @@ func resolveInstallTarget(target string) (string, error) {
148167
} else {
149168
for _, e := range c.Apps {
150169
if target == e.ID {
151-
return fetchAndUnpackBundle(e)
170+
dir, err := fetchAndUnpackBundle(e)
171+
return dir, installSourceCatalogue, err
152172
}
153173
}
154174
}
155175
info, err := os.Stat(target)
156176
if err == nil && info.IsDir() {
157-
return target, nil
177+
return target, installSourceLocal, nil
158178
}
159-
return "", fmt.Errorf("not a catalogue ID or a bundle dir: %q (try `pilotctl appstore catalogue` to list installable apps)", target)
179+
return "", installSourceLocal, fmt.Errorf("not a catalogue ID or a bundle dir: %q (try `pilotctl appstore catalogue` to list installable apps)", target)
160180
}
161181

162182
// fetchAndUnpackBundle downloads the catalogue entry's tarball,

cmd/pilotctl/zz_appstore_cmds_test.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"strings"
1010
"testing"
1111
"time"
12+
13+
"github.com/pilot-protocol/app-store/pkg/manifest"
1214
)
1315

1416
// minimalManifestJSON returns a manifest that passes JSON Parse but
@@ -54,7 +56,7 @@ func validManifestJSON(id, binSHA string) []byte {
5456
"grants": []any{
5557
map[string]any{
5658
"cap": "fs.read",
57-
"target": "/tmp/data",
59+
"target": "$APP/data",
5860
},
5961
},
6062
"protection": "shareable",
@@ -511,22 +513,28 @@ func TestCmdAppStoreInstallHappyPath(t *testing.T) {
511513
prev := jsonOutput
512514
defer func() { jsonOutput = prev }()
513515
jsonOutput = true
514-
out := captureStdout(t, func() { cmdAppStoreInstall([]string{bundleDir}) })
516+
// Local-path installs now require --local; without it the install
517+
// command refuses (see TestCmdAppStoreInstallRefusesLocalPathWithoutFlag).
518+
out := captureStdout(t, func() { cmdAppStoreInstall([]string{bundleDir, "--local"}) })
515519
var rpt installReport
516520
if err := json.Unmarshal([]byte(out), &rpt); err != nil {
517521
t.Fatalf("parse: %v\n%s", err, out)
518522
}
519523
if rpt.AppID != "io.test.install" {
520524
t.Errorf("AppID = %q", rpt.AppID)
521525
}
522-
// App should live under root/<id>/
526+
// App should live under root/<id>/ and carry the .sideloaded marker
527+
// — local-path installs are sideloads by definition.
523528
installedPath := filepath.Join(root, "io.test.install")
524529
if _, err := os.Stat(filepath.Join(installedPath, "manifest.json")); err != nil {
525530
t.Errorf("manifest not placed: %v", err)
526531
}
527532
if _, err := os.Stat(filepath.Join(installedPath, "bin", "app")); err != nil {
528533
t.Errorf("binary not placed: %v", err)
529534
}
535+
if _, err := os.Stat(filepath.Join(installedPath, manifest.SideloadMarkerName)); err != nil {
536+
t.Errorf("sideload marker not planted: %v", err)
537+
}
530538
}
531539

532540
func TestCmdAppStoreInstallRefusesDuplicate(t *testing.T) {
@@ -537,10 +545,10 @@ func TestCmdAppStoreInstallRefusesDuplicate(t *testing.T) {
537545
defer func() { jsonOutput = prev }()
538546
jsonOutput = true
539547
// First install OK.
540-
_ = captureStdout(t, func() { cmdAppStoreInstall([]string{bundleDir}) })
548+
_ = captureStdout(t, func() { cmdAppStoreInstall([]string{bundleDir, "--local"}) })
541549
// Second install would fatalCode — can't catch os.Exit inline. But
542550
// install with --force should succeed.
543-
out := captureStdout(t, func() { cmdAppStoreInstall([]string{bundleDir, "--force"}) })
551+
out := captureStdout(t, func() { cmdAppStoreInstall([]string{bundleDir, "--force", "--local"}) })
544552
var rpt installReport
545553
if err := json.Unmarshal([]byte(out), &rpt); err != nil {
546554
t.Fatalf("force install parse: %v\n%s", err, out)
@@ -699,8 +707,8 @@ func TestCmdAppStoreInstallTextMode(t *testing.T) {
699707
prev := jsonOutput
700708
defer func() { jsonOutput = prev }()
701709
jsonOutput = false
702-
out := captureStdout(t, func() { cmdAppStoreInstall([]string{bundleDir}) })
703-
for _, frag := range []string{"installed", "io.test.install.text", "daemon rescans"} {
710+
out := captureStdout(t, func() { cmdAppStoreInstall([]string{bundleDir, "--local"}) })
711+
for _, frag := range []string{"installed", "io.test.install.text", "daemon rescans", "SIDELOADED"} {
704712
if !strings.Contains(out, frag) {
705713
t.Errorf("missing %q in: %s", frag, out)
706714
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.25.10
44

55
require (
66
github.com/coder/websocket v1.8.14
7-
github.com/pilot-protocol/app-store v0.2.0
7+
github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260609061942-8852c785a264
88
github.com/pilot-protocol/beacon v0.2.5
99
github.com/pilot-protocol/common v0.4.8
1010
github.com/pilot-protocol/dataexchange v0.2.0

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM
44
github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
55
github.com/pilot-protocol/app-store v0.2.0 h1:/SbLPa2dKnvhv0ITeaYMZw/2KXXxWDRuYUdM5z9eyKk=
66
github.com/pilot-protocol/app-store v0.2.0/go.mod h1:f0umeJxswDG8/CctHpSFMlr5GLtE2GlPKkijIQErZuc=
7+
github.com/pilot-protocol/app-store v1.0.0-rc1.0.20260609015400-d02db7da3924 h1:pUK7tOsFIqYr2vwcfKT4t81+ntuVDbyffajkJa+2A3U=
8+
github.com/pilot-protocol/app-store v1.0.0-rc1.0.20260609015400-d02db7da3924/go.mod h1:f0umeJxswDG8/CctHpSFMlr5GLtE2GlPKkijIQErZuc=
9+
github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260609061942-8852c785a264 h1:NL9rFdakbVQ0V7xfJbCk8RJZSaQ1AmvdhAJwFIouMsk=
10+
github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260609061942-8852c785a264/go.mod h1:zoCxHYoNdj0V44OkG3Yzcye0jnwZDVUcJgAvR5Z1kwc=
711
github.com/pilot-protocol/beacon v0.2.5 h1:5+pkSPoA35r+u4Hfrph/ZfOltOyiy8lh1sCfK5XqXKs=
812
github.com/pilot-protocol/beacon v0.2.5/go.mod h1:I/UhEv097g1z/qtAVDZbEhf3R5tzM0Dp71vGHah52A4=
913
github.com/pilot-protocol/common v0.4.8 h1:eS2Bc+XcZWJ/qhwwOZbXwIWhtNdOijuoEp716kQE+/c=

scripts/gen-cli-reference.sh

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ TMP="$(mktemp)"
2525
echo '> Source: [`cmd/pilotctl/main.go`](../cmd/pilotctl/main.go).'
2626
echo ''
2727
echo '```text'
28-
./pilotctl --help
28+
# pilotctl --help writes to stderr (so the same banner appears
29+
# for `pilotctl <bad-flag>`) and exits non-zero by Go's flag
30+
# convention. Capture stderr → stdout so it lands in the doc,
31+
# and swallow the exit code so `set -euo pipefail` doesn't
32+
# abort before the diff step runs.
33+
./pilotctl --help 2>&1 || true
2934
echo '```'
3035
} > "$TMP"
3136

0 commit comments

Comments
 (0)