Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <app> <args>` 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
Expand Down
6 changes: 4 additions & 2 deletions pkg/manifest/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "<app>.<method>", or sign-purpose.
// Target: path pattern, host pattern, "<app>.<method>", 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.
Expand Down
76 changes: 76 additions & 0 deletions pkg/manifest/procexec_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
31 changes: 31 additions & 0 deletions pkg/manifest/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -192,13 +206,30 @@ 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)...)
}
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: <exactly one binary>").
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
Expand Down
Loading