Skip to content

Commit 07b4170

Browse files
Alex Godorojaclaude
andcommitted
manifest: add proc.exec capability (hardened target)
Add proc.exec to KnownCaps so an app can declare permission to spawn one local executable — the CLI it fronts. This unblocks the app-store CLI adapter archetype. The grant target must name exactly one binary: an absolute path or a bare command name ([A-Za-z0-9._-] segments). A wildcard, a path with '..', spaces, or any shell metacharacter is rejected at validation, so 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. proc.exec is intentionally NOT in the sideload allow-list — CLI apps install through the reviewed catalogue (guarded). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0513e62 commit 07b4170

4 files changed

Lines changed: 131 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,26 @@ First release candidate. Surface is end-to-end working for the
1313
Production deployment against an untrusted publisher requires the
1414
catalog-signing chain landed in a follow-up RC (see "Known gaps").
1515

16+
### Added — `proc.exec` capability
17+
18+
- **New capability `proc.exec`.** An app may declare permission to spawn one
19+
local executable — the CLI it fronts. This unblocks the app-store "CLI
20+
adapter" archetype (translate `pilotctl appstore call <app> <args>` into a
21+
local subprocess invocation).
22+
- **Hardened target.** The grant target must name exactly one binary: an
23+
absolute path (`/usr/local/bin/tool`) or a bare command name (`gh`),
24+
`[A-Za-z0-9._-]` segments only. A `*` wildcard, a path with `..`, spaces, or
25+
any shell metacharacter is rejected at validation — a `proc.exec` grant can
26+
never mean "run anything".
27+
- **Declaration-only, like `audit.log`.** The app execs the child itself, so
28+
there is no per-call broker hook; the capability is the install-consented,
29+
validated declaration of intent. CLI apps ship `protection: guarded`.
30+
- **Catalogue-only.** `proc.exec` is intentionally NOT added to the sideload
31+
allow-list, so an unreviewed `--local` app can never carry it — CLI apps
32+
install through the reviewed catalogue. OS-level exec sandboxing
33+
(`sandbox-exec` / seccomp `execve` allow-list) remains the documented next
34+
hardening step (`SideloadOSSandboxTODO`).
35+
1636
### Security & hardening — broker + supervisor
1737

1838
- **Broker authorization (deny-by-default).** Every brokered call is

pkg/manifest/manifest.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,12 @@ type Binary struct {
7575
// every privileged op and grants are the only thing that authorizes them.
7676
type Grant struct {
7777
// Cap: "fs.read" | "fs.write" | "net.dial" | "net.call" | "ipc.call" |
78-
// "key.sign" | "audit.log" | ...
78+
// "key.sign" | "audit.log" | "proc.exec" | ...
7979
Cap string `json:"cap"`
8080

81-
// Target: path pattern, host pattern, "<app>.<method>", or sign-purpose.
81+
// Target: path pattern, host pattern, "<app>.<method>", sign-purpose, or
82+
// (for proc.exec) the single executable the app may spawn — an absolute path
83+
// or a bare command name.
8284
Target string `json:"target"`
8385

8486
// Condition is optional. If absent, the grant is unconditional.

pkg/manifest/procexec_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package manifest
2+
3+
import "testing"
4+
5+
// proc.exec is a known capability and accepts an absolute-path target.
6+
func TestValidate_ProcExecAbsolutePath(t *testing.T) {
7+
t.Parallel()
8+
m := mustValid(t)
9+
m.Grants = append(m.Grants, Grant{Cap: "proc.exec", Target: "/usr/local/bin/weathercli"})
10+
if errs := m.Validate(); len(errs) != 0 {
11+
t.Fatalf("proc.exec with an absolute path must validate, got: %v", errs)
12+
}
13+
}
14+
15+
// proc.exec accepts a bare command name (resolved via PATH).
16+
func TestValidate_ProcExecBareCommand(t *testing.T) {
17+
t.Parallel()
18+
for _, cmd := range []string{"gh", "python3", "my-tool", "ripgrep"} {
19+
m := mustValid(t)
20+
m.Grants = append(m.Grants, Grant{Cap: "proc.exec", Target: cmd})
21+
if errs := m.Validate(); len(errs) != 0 {
22+
t.Errorf("proc.exec %q must validate, got: %v", cmd, errs)
23+
}
24+
}
25+
}
26+
27+
// A proc.exec target must name exactly one binary: no wildcard, no shell, no
28+
// spaces, no path traversal. Each of these must be rejected.
29+
func TestValidate_ProcExecRejectsUnsafeTargets(t *testing.T) {
30+
t.Parallel()
31+
bad := []string{
32+
"*", // wildcard — "run anything" is never allowed
33+
"/usr/bin/*", // path wildcard
34+
"sh -c 'rm -rf /'", // shell string with spaces
35+
"foo;bar", // command separator
36+
"foo|bar", // pipe
37+
"foo`id`", // command substitution
38+
"foo$(id)", // command substitution
39+
"../../bin/evil", // path traversal
40+
"/opt/../etc/cron.d/x", // traversal inside an absolute path
41+
"tool\nsecond", // newline injection
42+
}
43+
for _, target := range bad {
44+
m := mustValid(t)
45+
m.Grants = append(m.Grants, Grant{Cap: "proc.exec", Target: target})
46+
if !hasErrorContaining(m.Validate(), "proc.exec") {
47+
t.Errorf("proc.exec target %q must be rejected, but validation passed", target)
48+
}
49+
}
50+
}
51+
52+
// An empty proc.exec target hits the generic empty-target error (not the
53+
// proc.exec-specific one), same as any other cap.
54+
func TestValidate_ProcExecEmptyTarget(t *testing.T) {
55+
t.Parallel()
56+
m := mustValid(t)
57+
m.Grants = append(m.Grants, Grant{Cap: "proc.exec", Target: " "})
58+
if !hasErrorContaining(m.Validate(), "target must not be empty") {
59+
t.Errorf("empty proc.exec target should hit the empty-target error, got: %v", m.Validate())
60+
}
61+
}
62+
63+
// Security boundary: proc.exec is NOT in the sideload allow-list, so a CLI app
64+
// (which carries a proc.exec grant) can never be sideloaded — it must go through
65+
// the reviewed catalogue. This pins that boundary against accidental widening.
66+
func TestEnforceSideloadPolicy_RejectsProcExec(t *testing.T) {
67+
t.Parallel()
68+
if _, ok := SideloadAllowedCaps["proc.exec"]; ok {
69+
t.Fatal("proc.exec must NOT be in the sideload allow-list (it is catalogue-only)")
70+
}
71+
m := baseSideloadOK()
72+
m.Grants = append(m.Grants, Grant{Cap: "proc.exec", Target: "/usr/local/bin/tool"})
73+
if err := EnforceSideloadPolicy(m); err == nil {
74+
t.Fatal("sideload policy must reject a proc.exec grant")
75+
}
76+
}

pkg/manifest/validate.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,22 @@ var KnownCaps = map[string]bool{
1919
"ipc.call": true,
2020
"key.sign": true,
2121
"audit.log": true,
22+
// proc.exec: the app may spawn a local subprocess (the CLI it fronts). The
23+
// target names the single executable it may run — an absolute path or a bare
24+
// command name, never a wildcard or a shell string. Like audit.log this is a
25+
// declared, install-consented capability the app enforces itself (it execs
26+
// the child directly), not a per-call brokered one. See procExecTargetPattern.
27+
"proc.exec": true,
2228
}
2329

30+
// procExecTargetPattern constrains a proc.exec target to a single executable:
31+
// either an absolute path (/usr/local/bin/tool) or a bare command name resolved
32+
// via PATH (gh, python3, my-tool). Segments are limited to [A-Za-z0-9._-], so
33+
// spaces, shell metacharacters, and a "*" wildcard are all rejected — a
34+
// proc.exec grant must name exactly one binary, never "run anything". A ".."
35+
// path segment is rejected separately (see validateProcExecTarget).
36+
var procExecTargetPattern = regexp.MustCompile(`^/?[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$`)
37+
2438
// Known condition kinds.
2539
var KnownConditionKinds = map[string]bool{
2640
"rate": true,
@@ -192,13 +206,30 @@ func validateGrant(i int, g Grant) []error {
192206
}
193207
if strings.TrimSpace(g.Target) == "" {
194208
errs = append(errs, fmt.Errorf("grants[%d].target must not be empty", i))
209+
} else if g.Cap == "proc.exec" {
210+
errs = append(errs, validateProcExecTarget(i, g.Target)...)
195211
}
196212
if g.Condition != nil {
197213
errs = append(errs, validateCondition(fmt.Sprintf("grants[%d].if", i), *g.Condition)...)
198214
}
199215
return errs
200216
}
201217

218+
// validateProcExecTarget enforces the proc.exec target shape: a single
219+
// executable named as an absolute path or a bare command, with no path
220+
// traversal, no shell, and no wildcard. This keeps the install-consented
221+
// surface explicit ("this app may run: <exactly one binary>").
222+
func validateProcExecTarget(i int, target string) []error {
223+
t := strings.TrimSpace(target)
224+
if strings.Contains(t, "..") {
225+
return []error{fmt.Errorf("grants[%d].target %q for proc.exec must not contain %q", i, target, "..")}
226+
}
227+
if !procExecTargetPattern.MatchString(t) {
228+
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)}
229+
}
230+
return nil
231+
}
232+
202233
func validateCondition(path string, c Condition) []error {
203234
var errs []error
204235
hasLeaf := c.Kind != "" || len(c.Params) > 0

0 commit comments

Comments
 (0)