diff --git a/README.md b/README.md index f1e1ad3..255594a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 diff --git a/cli/args.go b/cli/args.go index d36ef19..37a6f7b 100644 --- a/cli/args.go +++ b/cli/args.go @@ -28,6 +28,7 @@ type Args struct { AllowNet bool AllowFS bool AllowCmd bool + Dangerous bool Check bool LogFile string LogFormat string @@ -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)") @@ -92,5 +94,6 @@ func (a *Args) BasicBoxOpts() *BoxOpts { allowNet: a.AllowNet, allowFS: a.AllowFS, allowCmd: a.AllowCmd, + dangerous: a.Dangerous, } } diff --git a/cli/box.go b/cli/box.go index f6b9858..e822a2d 100644 --- a/cli/box.go +++ b/cli/box.go @@ -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 @@ -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() { diff --git a/cli/capability.go b/cli/capability.go index e453d2c..3e28975 100644 --- a/cli/capability.go +++ b/cli/capability.go @@ -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 @@ -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 diff --git a/cli/capability_test.go b/cli/capability_test.go index 7faf21b..b8e954b 100644 --- a/cli/capability_test.go +++ b/cli/capability_test.go @@ -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" @@ -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 @@ -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) } @@ -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) + } + }) +} diff --git a/cli/mods.go b/cli/mods.go index 55d6632..0dd8ed1 100644 --- a/cli/mods.go +++ b/cli/mods.go @@ -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 diff --git a/go.mod b/go.mod index 6867503..3602e1b 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 9079a96..a4fbcc2 100644 --- a/go.sum +++ b/go.sum @@ -246,8 +246,8 @@ github.com/starpkg/base v0.1.1 h1:AeNOEIyuyQJoQjgU+AxAE3+1ebgLlsF1HmA49tdTOtU= github.com/starpkg/base v0.1.1/go.mod h1:0QLSawzBUCFLNk1jMRpzNs6AZH9SVpumd2J7WLOPrR0= github.com/starpkg/cache v0.1.0 h1:P3nJC2zIycblI+Xz57yyhaAJOLMsf8A6ngejGTG7GA4= github.com/starpkg/cache v0.1.0/go.mod h1:iYiUOUPJlJ2Z3CQVAO1T6RsQ6gg6g4/Cw6ssGvx3X68= -github.com/starpkg/cmd v0.1.0 h1:RyB0u6zixc05tbVj1Q7SoNumDlO1ZTy3sAaJA9xRdIA= -github.com/starpkg/cmd v0.1.0/go.mod h1:gjMdstyJeMiK70fZVR8uqr7ub3XnmJjRHDqOnKqabfI= +github.com/starpkg/cmd v0.1.1 h1:iFCfIcjJbtAxZ9qyss+jClYoOqDBv1kt+zWEth6L34o= +github.com/starpkg/cmd v0.1.1/go.mod h1:gjMdstyJeMiK70fZVR8uqr7ub3XnmJjRHDqOnKqabfI= github.com/starpkg/email v0.1.0 h1:KgV+9pmaE4fI9RTijjuYtZRjI3jRLzroW4wt+NW0Nr8= github.com/starpkg/email v0.1.0/go.mod h1:V9CnDje/JydsHvGMNaukM8sn/bS6yUdT7qwgG8X8JQg= github.com/starpkg/emoji v0.1.0 h1:GNYtnkZgD1rucptKtjfUVWiAbsqfzqPncoFqVcFVtlE=