diff --git a/CHANGELOG.md b/CHANGELOG.md index b70e90d..6785ae0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,26 @@ First release candidate. Surface is end-to-end working for the Production deployment against an untrusted publisher requires the catalog-signing chain landed in a follow-up RC (see "Known gaps"). +### Added — `proc.exec` capability + +- **New capability `proc.exec`.** An app may declare permission to spawn one + local executable — the CLI it fronts. This unblocks the app-store "CLI + adapter" archetype (translate `pilotctl appstore call ` into a + local subprocess invocation). +- **Hardened target.** The grant target must name exactly one binary: an + absolute path (`/usr/local/bin/tool`) or a bare command name (`gh`), + `[A-Za-z0-9._-]` segments only. A `*` wildcard, a path with `..`, spaces, or + any shell metacharacter is rejected at validation — a `proc.exec` grant can + never mean "run anything". +- **Declaration-only, like `audit.log`.** The app execs the child itself, so + there is no per-call broker hook; the capability is the install-consented, + validated declaration of intent. CLI apps ship `protection: guarded`. +- **Catalogue-only.** `proc.exec` is intentionally NOT added to the sideload + allow-list, so an unreviewed `--local` app can never carry it — CLI apps + install through the reviewed catalogue. OS-level exec sandboxing + (`sandbox-exec` / seccomp `execve` allow-list) remains the documented next + hardening step (`SideloadOSSandboxTODO`). + ### Security & hardening — broker + supervisor - **Broker authorization (deny-by-default).** Every brokered call is diff --git a/pkg/manifest/manifest.go b/pkg/manifest/manifest.go index 5421b48..188c75b 100644 --- a/pkg/manifest/manifest.go +++ b/pkg/manifest/manifest.go @@ -75,10 +75,12 @@ type Binary struct { // every privileged op and grants are the only thing that authorizes them. type Grant struct { // Cap: "fs.read" | "fs.write" | "net.dial" | "net.call" | "ipc.call" | - // "key.sign" | "audit.log" | ... + // "key.sign" | "audit.log" | "proc.exec" | ... Cap string `json:"cap"` - // Target: path pattern, host pattern, ".", or sign-purpose. + // Target: path pattern, host pattern, ".", sign-purpose, or + // (for proc.exec) the single executable the app may spawn — an absolute path + // or a bare command name. Target string `json:"target"` // Condition is optional. If absent, the grant is unconditional. diff --git a/pkg/manifest/procexec_test.go b/pkg/manifest/procexec_test.go new file mode 100644 index 0000000..f53ba90 --- /dev/null +++ b/pkg/manifest/procexec_test.go @@ -0,0 +1,76 @@ +package manifest + +import "testing" + +// proc.exec is a known capability and accepts an absolute-path target. +func TestValidate_ProcExecAbsolutePath(t *testing.T) { + t.Parallel() + m := mustValid(t) + m.Grants = append(m.Grants, Grant{Cap: "proc.exec", Target: "/usr/local/bin/weathercli"}) + if errs := m.Validate(); len(errs) != 0 { + t.Fatalf("proc.exec with an absolute path must validate, got: %v", errs) + } +} + +// proc.exec accepts a bare command name (resolved via PATH). +func TestValidate_ProcExecBareCommand(t *testing.T) { + t.Parallel() + for _, cmd := range []string{"gh", "python3", "my-tool", "ripgrep"} { + m := mustValid(t) + m.Grants = append(m.Grants, Grant{Cap: "proc.exec", Target: cmd}) + if errs := m.Validate(); len(errs) != 0 { + t.Errorf("proc.exec %q must validate, got: %v", cmd, errs) + } + } +} + +// A proc.exec target must name exactly one binary: no wildcard, no shell, no +// spaces, no path traversal. Each of these must be rejected. +func TestValidate_ProcExecRejectsUnsafeTargets(t *testing.T) { + t.Parallel() + bad := []string{ + "*", // wildcard — "run anything" is never allowed + "/usr/bin/*", // path wildcard + "sh -c 'rm -rf /'", // shell string with spaces + "foo;bar", // command separator + "foo|bar", // pipe + "foo`id`", // command substitution + "foo$(id)", // command substitution + "../../bin/evil", // path traversal + "/opt/../etc/cron.d/x", // traversal inside an absolute path + "tool\nsecond", // newline injection + } + for _, target := range bad { + m := mustValid(t) + m.Grants = append(m.Grants, Grant{Cap: "proc.exec", Target: target}) + if !hasErrorContaining(m.Validate(), "proc.exec") { + t.Errorf("proc.exec target %q must be rejected, but validation passed", target) + } + } +} + +// An empty proc.exec target hits the generic empty-target error (not the +// proc.exec-specific one), same as any other cap. +func TestValidate_ProcExecEmptyTarget(t *testing.T) { + t.Parallel() + m := mustValid(t) + m.Grants = append(m.Grants, Grant{Cap: "proc.exec", Target: " "}) + if !hasErrorContaining(m.Validate(), "target must not be empty") { + t.Errorf("empty proc.exec target should hit the empty-target error, got: %v", m.Validate()) + } +} + +// Security boundary: proc.exec is NOT in the sideload allow-list, so a CLI app +// (which carries a proc.exec grant) can never be sideloaded — it must go through +// the reviewed catalogue. This pins that boundary against accidental widening. +func TestEnforceSideloadPolicy_RejectsProcExec(t *testing.T) { + t.Parallel() + if _, ok := SideloadAllowedCaps["proc.exec"]; ok { + t.Fatal("proc.exec must NOT be in the sideload allow-list (it is catalogue-only)") + } + m := baseSideloadOK() + m.Grants = append(m.Grants, Grant{Cap: "proc.exec", Target: "/usr/local/bin/tool"}) + if err := EnforceSideloadPolicy(m); err == nil { + t.Fatal("sideload policy must reject a proc.exec grant") + } +} diff --git a/pkg/manifest/validate.go b/pkg/manifest/validate.go index d9a76de..9156bc3 100644 --- a/pkg/manifest/validate.go +++ b/pkg/manifest/validate.go @@ -19,8 +19,22 @@ var KnownCaps = map[string]bool{ "ipc.call": true, "key.sign": true, "audit.log": true, + // proc.exec: the app may spawn a local subprocess (the CLI it fronts). The + // target names the single executable it may run — an absolute path or a bare + // command name, never a wildcard or a shell string. Like audit.log this is a + // declared, install-consented capability the app enforces itself (it execs + // the child directly), not a per-call brokered one. See procExecTargetPattern. + "proc.exec": true, } +// procExecTargetPattern constrains a proc.exec target to a single executable: +// either an absolute path (/usr/local/bin/tool) or a bare command name resolved +// via PATH (gh, python3, my-tool). Segments are limited to [A-Za-z0-9._-], so +// spaces, shell metacharacters, and a "*" wildcard are all rejected — a +// proc.exec grant must name exactly one binary, never "run anything". A ".." +// path segment is rejected separately (see validateProcExecTarget). +var procExecTargetPattern = regexp.MustCompile(`^/?[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$`) + // Known condition kinds. var KnownConditionKinds = map[string]bool{ "rate": true, @@ -192,6 +206,8 @@ func validateGrant(i int, g Grant) []error { } if strings.TrimSpace(g.Target) == "" { errs = append(errs, fmt.Errorf("grants[%d].target must not be empty", i)) + } else if g.Cap == "proc.exec" { + errs = append(errs, validateProcExecTarget(i, g.Target)...) } if g.Condition != nil { errs = append(errs, validateCondition(fmt.Sprintf("grants[%d].if", i), *g.Condition)...) @@ -199,6 +215,21 @@ func validateGrant(i int, g Grant) []error { return errs } +// validateProcExecTarget enforces the proc.exec target shape: a single +// executable named as an absolute path or a bare command, with no path +// traversal, no shell, and no wildcard. This keeps the install-consented +// surface explicit ("this app may run: "). +func validateProcExecTarget(i int, target string) []error { + t := strings.TrimSpace(target) + if strings.Contains(t, "..") { + return []error{fmt.Errorf("grants[%d].target %q for proc.exec must not contain %q", i, target, "..")} + } + if !procExecTargetPattern.MatchString(t) { + return []error{fmt.Errorf("grants[%d].target %q for proc.exec must be an absolute path or a bare command name (no wildcard, spaces, or shell metacharacters)", i, target)} + } + return nil +} + func validateCondition(path string, c Condition) []error { var errs []error hasLeaf := c.Kind != "" || len(c.Params) > 0