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
64 changes: 38 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,27 +63,28 @@ docker run -v $(pwd):/scripts starcli sh -c "/root/starcli /scripts/your-script.
```bash
$ ./starcli -h
Usage of ./starcli:
--allow-cmd widen a restrictive tier with the cmd module (host command execution)
--allow-fs widen a restrictive tier with filesystem modules (file, path)
--allow-net widen a restrictive tier with network modules (http, net, email, llm)
--caps string capability tier: open (default, everything) | full | network | safe; or env STAR_CAPS
--check syntax/resolve check the script (-c or file) without running it
-c, --code string Starlark code to execute
-C, --config string config file to load
-g, --globalreassign allow reassigning global variables in Starlark code (default true)
-I, --include string include path for Starlark code to load modules from (default ".")
-i, --interactive enter interactive mode after executing
-l, --log string log level: debug, info, warn, error, dpanic, panic, fatal (default "info")
--log-file string append the script's log module output to this file
--log-format string log file format: console (human) or json (machine) (default "console")
--max-output uint max top-level output entries per run (0=unlimited)
--max-steps uint max Starlark execution steps per run, guards runaway loops (0=unlimited)
-m, --module strings allowed modules to preload and load (default [args,atom,base64,cache,cmd,csv,email,emoji,file,go_idiomatic,gum,hashlib,http,json,liquid,llm,log,markdown,math,net,path,qrcode,random,re,regex,runtime,serial,sqlite,stats,string,struct,sys,time,toml,totp,web,yaml])
-o, --output string output printer: none,stdout,stderr,basic,lineno,since,auto (default "auto")
--record string record the complete session output (stdout+stderr) to this transcript file
-r, --recursion allow recursion in Starlark code
-V, --version print version & build information
-w, --web uint16 run web server on specified port, it provides request and response structs for Starlark code to use
--allow-cmd enable the cmd module to run ANY host command (no allowlist); also widens a restrictive tier
--allow-fs widen a restrictive tier with filesystem modules (file, path)
--allow-net widen a restrictive tier with network modules (http, net, email, llm)
--caps string capability tier: open (default, everything) | full | network | safe; or env STAR_CAPS
--check syntax/resolve check the script (-c or file) without running it
-c, --code string Starlark code to execute
-C, --config string config file to load
--dangerously-allow-all DANGER: open everything — network + filesystem + host command execution of ANY command. Use only with fully trusted scripts.
-g, --globalreassign allow reassigning global variables in Starlark code (default true)
-I, --include string include path for Starlark code to load modules from (default ".")
-i, --interactive enter interactive mode after executing
-l, --log string log level: debug, info, warn, error, dpanic, panic, fatal (default "info")
--log-file string append the script's log module output to this file
--log-format string log file format: console (human) or json (machine) (default "console")
--max-output uint max top-level output entries per run (0=unlimited)
--max-steps uint max Starlark execution steps per run, guards runaway loops (0=unlimited)
-m, --module strings allowed modules to preload and load (default [args,atom,base64,cache,cmd,csv,email,emoji,file,go_idiomatic,gum,hashlib,http,json,liquid,llm,log,markdown,math,net,path,qrcode,random,re,regex,runtime,serial,sqlite,stats,string,struct,sys,time,toml,totp,web,yaml])
-o, --output string output printer: none,stdout,stderr,basic,lineno,since,auto (default "auto")
--record string record the complete session output (stdout+stderr) to this transcript file
-r, --recursion allow recursion in Starlark code
-V, --version print version & build information
-w, --web uint16 run web server on specified port, it provides request and response structs for Starlark code to use
```

### Capabilities & sandboxing
Expand All @@ -94,18 +95,26 @@ just work. To sandbox an untrusted script, **tighten** the capability tier with

| tier | loadable modules |
|---|---|
| _(default)_ `open` | everything, including `cmd` (host command execution) |
| _(default)_ `open` | everything loadable; `cmd` loads but command **execution** stays off until `--allow-cmd` |
| `--caps full` | network **and** filesystem (but **not** `cmd`) |
| `--caps network` | safe **+** network (`http`, `net`, `email`, `llm`) |
| `--caps safe` | pure / log / process only (`math`, `json`, `sys`, `gum`, `markdown`, …) |

From a restrictive tier the granular flags widen the grant: `--allow-net`,
`--allow-fs`, and `--allow-cmd` (cmd is **never** granted by a tier — not even
`full` — only by `--allow-cmd`). A module is classified by the **union** of
`--allow-fs`, and `--allow-cmd`. A module is classified by the **union** of
everything it can do, so the dual-capability modules — `web` (HTTP **+**
`static_dir`) and `sqlite` (DB **+** remote `connect_remote`) — need **both**
`--allow-net` and `--allow-fs` (or `--caps full`).

**Host command execution (`cmd`) is the sharpest tool and is gated on its own.**
The `cmd` module loads in the open posture, but `run()` is **disabled** — it
returns an error — until you pass `--allow-cmd`, which **enables execution of any
command** (no allowlist; still argv-only, never a shell). `cmd` is never granted
by a tier, not even `full`. For a one-flag "trust everything" run there is
**`--dangerously-allow-all`** — it opens network **+** filesystem **+** host
command execution of any command in a single switch. Use it only with fully
trusted scripts.

Set a stricter default for a whole deployment with the env var:

```bash
Expand All @@ -127,8 +136,11 @@ $ ./starcli --caps safe -c 'load("http", "get")' # fails (exit 4)
# from safe, opt back into the network
$ ./starcli --caps safe --allow-net -c 'load("http", "get"); print(get)'

# host command execution always needs its own explicit flag
$ ./starcli --caps safe --allow-cmd -c 'load("cmd", "run"); print(run("echo", "hi"))'
# host command execution needs its own explicit flag (then run() runs anything)
$ ./starcli --allow-cmd -c 'load("cmd", "run"); print(run("go version").stdout)'

# one-flag "trust everything": network + filesystem + run any command
$ ./starcli --dangerously-allow-all script.star
```

### Examples
Expand Down
5 changes: 4 additions & 1 deletion cli/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type Args struct {
AllowNet bool
AllowFS bool
AllowCmd bool
Dangerous bool
Check bool
LogFile string
LogFormat string
Expand Down Expand Up @@ -55,7 +56,8 @@ func ParseArgs() *Args {
flag.StringVar(&args.Caps, "caps", "", "capability tier: open (default, everything) | full | network | safe; or env STAR_CAPS")
flag.BoolVar(&args.AllowNet, "allow-net", false, "widen a restrictive tier with network modules (http, net, email, llm)")
flag.BoolVar(&args.AllowFS, "allow-fs", false, "widen a restrictive tier with filesystem modules (file, path)")
flag.BoolVar(&args.AllowCmd, "allow-cmd", false, "widen a restrictive tier with the cmd module (host command execution)")
flag.BoolVar(&args.AllowCmd, "allow-cmd", false, "enable the cmd module to run ANY host command (no allowlist); also widens a restrictive tier")
flag.BoolVar(&args.Dangerous, "dangerously-allow-all", false, "DANGER: open everything — network + filesystem + host command execution of ANY command. Use only with fully trusted scripts.")
flag.BoolVar(&args.Check, "check", false, "syntax/resolve check the script (-c or file) without running it")
flag.StringVar(&args.LogFile, "log-file", "", "append the script's log module output to this file")
flag.StringVar(&args.LogFormat, "log-format", "console", "log file format: console (human) or json (machine)")
Expand Down Expand Up @@ -92,5 +94,6 @@ func (a *Args) BasicBoxOpts() *BoxOpts {
allowNet: a.AllowNet,
allowFS: a.AllowFS,
allowCmd: a.AllowCmd,
dangerous: a.Dangerous,
}
}
7 changes: 6 additions & 1 deletion cli/box.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ type BoxOpts struct {
allowNet bool // widen the grant with network modules
allowFS bool // widen the grant with filesystem modules
allowCmd bool // allow the cmd (host command execution) module
dangerous bool // --dangerously-allow-all: open everything + run any command
execCmd bool // derived from the grant: construct cmd ENABLED (allow-all)
}

// BuildBox creates a new Starbox with the given options. By default every wired
Expand All @@ -49,7 +51,10 @@ func BuildBox(opts *BoxOpts) (*starbox.Starbox, error) {
if !validCapsTier(opts.caps) {
return nil, fmt.Errorf("unknown --caps value %q (want: open, full, network, or safe)", opts.caps)
}
grant := grantFromFlags(opts.caps, opts.allowNet, opts.allowFS, opts.allowCmd)
grant := grantFromFlags(opts.caps, opts.allowNet, opts.allowFS, opts.allowCmd, opts.dangerous)
// The cmd loader (loadCLIModuleByName) constructs an enabled allow-all module
// only when the grant permits execution; otherwise cmd loads disabled.
opts.execCmd = grant.execCmd

var box *starbox.Starbox
if grant.unrestricted() {
Expand Down
28 changes: 22 additions & 6 deletions cli/capability.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,33 @@ func validCapsTier(s string) bool {
}

// capGrant is the set of capabilities a run permits, derived from the flags.
//
// Two distinct cmd levers: allowCmd lets the cmd module LOAD; execCmd decides
// whether it is constructed ENABLED (run any command) or disabled (run() errors,
// which() still works). Command EXECUTION is off in the open posture and turns
// on only when the operator explicitly opts in with --allow-cmd or
// --dangerously-allow-all — so a no-flag run can never spawn a process.
type capGrant struct {
caps starlet.ModuleCapability // permitted capability bits
allowCmd bool // host command execution (cmd), gated alone
allowCmd bool // the cmd module may load
execCmd bool // cmd is enabled (allow-all) — run() executes
}

// grantFromFlags builds a capGrant from the capability flags. The tier sets a
// baseline (the default — empty or "open" — is fully open, including exec) and
// the granular --allow-* flags only widen it. Validate the tier with
// capability baseline (the default — empty or "open" — opens net/fs); the
// granular --allow-* flags only widen it. --allow-cmd both loads and ENABLES the
// cmd module; --dangerously-allow-all is the one-flag "open everything, run any
// command" escape hatch that overrides the tier. Validate the tier with
// validCapsTier before calling; an unrecognised value is treated as open here.
func grantFromFlags(caps string, allowNet, allowFS, allowCmd bool) capGrant {
g := capGrant{allowCmd: allowCmd}
func grantFromFlags(caps string, allowNet, allowFS, allowCmd, dangerous bool) capGrant {
if dangerous {
// One flag opens every capability AND enables host command execution of
// ANY command — the sharpest posture, for fully trusted scripts only.
return capGrant{caps: allCaps, allowCmd: true, execCmd: true}
}
// --allow-cmd both lets cmd load and enables execution; without it the open
// posture still loads cmd but run() stays disabled (execCmd false).
g := capGrant{allowCmd: allowCmd, execCmd: allowCmd}
switch strings.ToLower(strings.TrimSpace(caps)) {
case "safe":
g.caps = safeCaps
Expand All @@ -93,7 +109,7 @@ func grantFromFlags(caps string, allowNet, allowFS, allowCmd bool) capGrant {
g.caps = allCaps
default: // "" or "open" (or, defensively, anything unrecognised) -> open
g.caps = allCaps
g.allowCmd = true
g.allowCmd = true // cmd still LOADS by default (which() works); run() needs execCmd
}
if allowNet {
g.caps |= starlet.CapNetwork
Expand Down
119 changes: 106 additions & 13 deletions cli/capability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ package cli
// - open default loads everything; invalid tier errors
// - end-to-end gating through Process under an explicit tier
// - wired pure domain modules are classified pure and pass the safe gate
// - cmd execution gating: off by default, on with --allow-cmd /
// --dangerously-allow-all (the execCmd lever + end-to-end run())

import (
"strings"
Expand All @@ -19,16 +21,16 @@ import (
)

func TestModuleAllowed(t *testing.T) {
safe := grantFromFlags("safe", false, false, false)
network := grantFromFlags("network", false, false, false)
allowNet := grantFromFlags("safe", true, false, false)
allowFS := grantFromFlags("safe", false, true, false)
full := grantFromFlags("full", false, false, false)
fullCmd := grantFromFlags("full", false, false, true)
allowCmd := grantFromFlags("safe", false, false, true)
netFS := grantFromFlags("safe", true, true, false) // --allow-net --allow-fs
open := grantFromFlags("", false, false, false) // default/empty == open
openX := grantFromFlags("open", false, false, false)
safe := grantFromFlags("safe", false, false, false, false)
network := grantFromFlags("network", false, false, false, false)
allowNet := grantFromFlags("safe", true, false, false, false)
allowFS := grantFromFlags("safe", false, true, false, false)
full := grantFromFlags("full", false, false, false, false)
fullCmd := grantFromFlags("full", false, false, true, false)
allowCmd := grantFromFlags("safe", false, false, true, false)
netFS := grantFromFlags("safe", true, true, false, false) // --allow-net --allow-fs
open := grantFromFlags("", false, false, false, false) // default/empty == open
openX := grantFromFlags("open", false, false, false, false)

cases := []struct {
grant capGrant
Expand Down Expand Up @@ -82,15 +84,15 @@ func TestModuleAllowed(t *testing.T) {

func TestAllowedModules(t *testing.T) {
in := []string{"math", "http", "cmd", "sqlite", "sys"}
safe := grantFromFlags("safe", false, false, false).allowedModules(in)
safe := grantFromFlags("safe", false, false, false, false).allowedModules(in)
if got := strings.Join(safe, ","); got != "math,sys" {
t.Errorf("safe allowedModules=%v want [math sys]", safe)
}
full := grantFromFlags("full", false, false, false).allowedModules(in)
full := grantFromFlags("full", false, false, false, false).allowedModules(in)
if got := strings.Join(full, ","); got != "math,http,sqlite,sys" {
t.Errorf("full allowedModules=%v want [math http sqlite sys]", full)
}
fullCmd := grantFromFlags("full", false, false, true).allowedModules(in)
fullCmd := grantFromFlags("full", false, false, true, false).allowedModules(in)
if got := strings.Join(fullCmd, ","); got != "math,http,cmd,sqlite,sys" {
t.Errorf("full+cmd allowedModules=%v want all", fullCmd)
}
Expand Down Expand Up @@ -215,3 +217,94 @@ func TestPureDomainModulesAreSafe(t *testing.T) {
t.Errorf("emoji.emojize output=%q, want a waving-hand emoji", so)
}
}

// --- cmd execution gating ----------------------------------------------------

// TestGrantExecCmd pins the execCmd lever: command execution is OFF in the open
// posture and under a bare tier, ON only with --allow-cmd or the one-flag
// --dangerously-allow-all. allowCmd (loadability) is tracked separately.
func TestGrantExecCmd(t *testing.T) {
cases := []struct {
name string
grant capGrant
wantExec, wantAllow bool
}{
{"open default loads cmd but no exec", grantFromFlags("", false, false, false, false), false, true},
{"full without allow-cmd: neither", grantFromFlags("full", false, false, false, false), false, false},
{"allow-cmd: load + exec", grantFromFlags("safe", false, false, true, false), true, true},
{"dangerously-allow-all: load + exec", grantFromFlags("safe", false, false, false, true), true, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if c.grant.execCmd != c.wantExec {
t.Errorf("execCmd=%v want %v", c.grant.execCmd, c.wantExec)
}
if c.grant.allowCmd != c.wantAllow {
t.Errorf("allowCmd=%v want %v", c.grant.allowCmd, c.wantAllow)
}
})
}
}

// TestProcess_CmdExecution_EndToEnd proves the wiring end-to-end: run() executes
// only when the operator opted in. "go" is on PATH in CI on every OS, and no
// allowlist permits it, so a successful run proves the allow-all enablement.
func TestProcess_CmdExecution_EndToEnd(t *testing.T) {
runGo := `
load("cmd", "run")
res = run("go version")
print(res.success)
print("go version" in res.stdout)
`

t.Run("default open: run() is disabled", func(t *testing.T) {
a := baseArgs() // no flags -> execCmd false
a.OutputPrinter = "stdout"
a.CodeContent = runGo
var rc int
_, se := captureStd(t, func() { rc = Process(a) })
if rc == 0 {
t.Errorf("default run() must be disabled, got exit 0")
}
if !strings.Contains(se, "disabled") {
t.Errorf("stderr %q should report command execution is disabled", se)
}
})

t.Run("--allow-cmd runs any command", func(t *testing.T) {
a := baseArgs()
a.AllowCmd = true
a.OutputPrinter = "stdout"
a.CodeContent = runGo
var rc int
so, se := captureStd(t, func() { rc = Process(a) })
if rc != 0 {
t.Fatalf("--allow-cmd run('go version') exit=%d stderr=%q", rc, se)
}
if strings.Count(so, "True") < 2 {
t.Errorf("--allow-cmd should run an un-allowlisted command, got:\n%s", so)
}
})

t.Run("--dangerously-allow-all opens net + cmd in one flag", func(t *testing.T) {
a := baseArgs()
a.Dangerous = true
a.OutputPrinter = "stdout"
// http (a network module) loading proves net is open; res.success proves
// cmd executes — both under the single flag.
a.CodeContent = `
load("http", "get")
load("cmd", "run")
res = run("go version")
print(res.success)
`
var rc int
so, se := captureStd(t, func() { rc = Process(a) })
if rc != 0 {
t.Fatalf("--dangerously-allow-all exit=%d stderr=%q", rc, se)
}
if !strings.Contains(so, "True") {
t.Errorf("--dangerously-allow-all should load http and run cmd, got:\n%s", so)
}
})
}
7 changes: 7 additions & 0 deletions cli/mods.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ func loadCLIModuleByName(opts *BoxOpts, name string) (starlet.ModuleLoader, erro
case markdown.ModuleName:
return markdown.NewModule().LoadModule(), nil
case cmd.ModuleName:
// Command execution is off unless the operator opted in (--allow-cmd or
// --dangerously-allow-all → execCmd). Enabled means allow-all: any command
// runs (still argv-only + input-hardened by the cmd module). Otherwise the
// module loads disabled — which() works, run() returns a clear error.
if opts.execCmd {
return cmd.NewModuleWithAllowAll().LoadModule(), nil
}
return cmd.NewModule().LoadModule(), nil
case sqlite.ModuleName:
return sqlite.NewModule().LoadModule(), nil
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ require (
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.16.0
github.com/starpkg/cache v0.1.0
github.com/starpkg/cmd v0.1.0
github.com/starpkg/cmd v0.1.1
github.com/starpkg/email v0.1.0
github.com/starpkg/emoji v0.1.0
github.com/starpkg/gum v0.2.0
Expand Down
Loading
Loading