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
3 changes: 3 additions & 0 deletions cmd/onecli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,9 @@ func (cmd *HelpCmd) Run(out *output.Writer) error {
{Name: "auth regenerate-api-key", Description: "Regenerate your API key."},
{Name: "config get <key>", Description: "Get a config value. Keys: api-host, project, agent."},
{Name: "config set <key> <value>", Description: "Set a config value. Keys: api-host, project, agent."},
{Name: "sandbox audit", Description: "Red-team the enforce-mode sandbox: attempt every known egress bypass and report which the OS actually stops. Exits non-zero if any hole is found.", Args: []ArgInfo{
{Name: "<agent>", Description: "Agent to audit (codex, cursor, claude, ...). Defaults to the OneCLI-owned sandbox."},
}},
{Name: "migrate", Description: "Migrate data to OneCLI Cloud.", Args: []ArgInfo{
{Name: "--cloud-key", Required: true, Description: "OneCLI Cloud API key."},
}},
Expand Down
1 change: 1 addition & 0 deletions cmd/onecli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type CLI struct {
Counts CountsCmd `cmd:"" help:"Show the project's resource counts."`
Auth AuthCmd `cmd:"" help:"Manage authentication."`
Config ConfigCmd `cmd:"" help:"Manage configuration settings."`
Sandbox SandboxCmd `cmd:"" help:"Inspect and audit the enforce-mode sandbox."`
Migrate MigrateCmd `cmd:"" help:"Migrate data to OneCLI Cloud."`
}

Expand Down
110 changes: 99 additions & 11 deletions cmd/onecli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ type RunCmd struct {
Agent string `optional:"" name:"agent" help:"OneCLI agent identifier (default: ONECLI_AGENT env, then 'onecli config set agent', then the project's default agent)."`
Gateway string `optional:"" name:"gateway" help:"Gateway host:port override (default: derived from API host)."`
NoCA bool `optional:"" name:"no-ca" help:"Skip writing the CA cert and CA trust env injection."`
Enforce bool `optional:"" name:"enforce" help:"OS-enforced governance: route the agent's sandboxed egress through the gateway so it cannot be bypassed (Claude Code only)."`
Enforce bool `optional:"" name:"enforce" help:"OS-enforced governance: sandbox the agent so all egress is routed through the gateway and cannot be bypassed."`
DryRun bool `optional:"" name:"dry-run" help:"Print resolved env and command without executing."`
Args []string `arg:"" optional:"" name:"command" help:"Command and arguments to execute (after --)."`
}
Expand Down Expand Up @@ -136,11 +136,48 @@ func (c *RunCmd) Run(out *output.Writer) error {
}
}

// Enforce mode routes one of two ways. Agents with a cooperating
// OS sandbox (Claude Code) keep the native integration: their own
// sandbox becomes the enforcement layer via --settings. Everything
// else gets the OneCLI-owned sandbox wrap (run_enforce_wrap.go).
// The wrap decision must happen HERE — before the child env and any
// agent config injections are derived from cfg.Env — because the
// wrapped process can only dial loopback, so every proxy URL has to
// be repointed at the forwarder first. Both paths fail closed.
enforceNative := false
wrapProfilePath := ""
var wrapPort uint16
if c.Enforce {
spec, known := agentSkillDir(c.Args[0])
switch {
case known && enforceSupportedAgents[spec.agentName]:
enforceNative = true
case known && spec.dockerSandbox:
// Tools run in a Docker container OUTSIDE any host sandbox
// (and the wrap denies docker.sock as an egress bypass), so
// the wrap cannot govern this agent. Fail closed.
return fmt.Errorf("--enforce is not supported for %s: its tools run in a Docker sandbox the OS sandbox cannot govern", spec.agentName)
default:
wrapProfilePath, wrapPort, err = resolveEnforceWrap(cfg.Env)
if err != nil {
return fmt.Errorf("enforce mode unavailable: %w", err)
}
}
}

// Build child environment.
env := buildChildEnv(os.Environ(), cfg.Env, caPath)

env = append(env, "ONECLI_GATEWAY=true")

// When the gateway routes Node's own egress via NODE_USE_ENV_PROXY, Node
// prints a one-time "[UNDICI-EHPA] EnvHttpProxyAgent is experimental"
// warning to stderr on startup — the mechanism announcing itself, not an
// error. Mute just that warning code (Node 22+ --disable-warning) so
// gateway plumbing doesn't leak noise into the agent's output. Scoped to
// the flag's presence so we never alter Node behavior otherwise.
env = suppressUndiciProxyWarning(env)

// For known agents, fetch the agent-specific skill variant and install
// to the agent's skill directory. Also optionally register a hook.
agentFramework := strings.ToLower(filepath.Base(c.Args[0]))
Expand Down Expand Up @@ -207,16 +244,12 @@ func (c *RunCmd) Run(out *output.Writer) error {
out.Stderr(fmt.Sprintf("onecli: warning: %s", w))
}

// Enforce mode: fork the loopback auth forwarder, write the sandbox
// settings, and extend the agent argv. Fails closed — a broken
// forwarder would leave the sandbox with no route to the gateway,
// which is worse than an explicit error.
// Native enforce path: fork the loopback auth forwarder, write the
// sandbox settings, and extend the agent argv. Fails closed — a
// broken forwarder would leave the sandbox with no route to the
// gateway, which is worse than an explicit error.
args := c.Args
if c.Enforce {
a, ok := agentSkillDir(c.Args[0])
if !ok || !enforceSupportedAgents[a.agentName] {
return fmt.Errorf("--enforce is not supported for %q yet (Claude Code only)", filepath.Base(c.Args[0]))
}
if enforceNative {
port, err := spawnEnforceForwarder(firstProxyURL(cfg.Env))
if err != nil {
return fmt.Errorf("enforce mode unavailable: %w", err)
Expand All @@ -229,9 +262,24 @@ func (c *RunCmd) Run(out *output.Writer) error {
out.Stderr(fmt.Sprintf("onecli: enforce mode active — sandboxed egress locked to the gateway (forwarder :%d).", port))
}

// Wrap enforce path: exec sandbox-exec around the agent so the OS
// confines the whole process tree to loopback-only egress.
execBinary := binary
if wrapProfilePath != "" {
execBinary, err = enforceWrapLauncher()
if err != nil {
return fmt.Errorf("enforce mode unavailable: %w", err)
}
args = enforceWrapArgv(wrapProfilePath, binary, c.Args[1:], agentFramework)
if notice := enforceWrapNotice(agentFramework); notice != "" {
out.Stderr(notice)
}
out.Stderr(fmt.Sprintf("onecli: enforce mode active — all process egress locked to the gateway (forwarder :%d).", wrapPort))
}

// Exec — replaces this process so the agent gets direct terminal control.
out.Stderr(fmt.Sprintf("onecli: gateway connected. Starting %s...", c.Args[0]))
if err := syscall.Exec(binary, args, env); err != nil {
if err := syscall.Exec(execBinary, args, env); err != nil {
return fmt.Errorf("could not start %s: %w", c.Args[0], err)
}
return nil
Expand Down Expand Up @@ -363,6 +411,46 @@ func buildChildEnv(current []string, serverEnv map[string]string, caPath string)
return out
}

// undiciWarningFlag mutes Node's experimental EnvHttpProxyAgent warning by its
// stable code (Node 22+). Scoped to a single code so real warnings still show.
const undiciWarningFlag = "--disable-warning=UNDICI-EHPA"

// suppressUndiciProxyWarning appends undiciWarningFlag to NODE_OPTIONS, but only
// when the gateway has enabled Node's env-proxy support (NODE_USE_ENV_PROXY) —
// the setting that triggers the warning. It edits an existing NODE_OPTIONS in
// place (preserving the user's flags) or appends a new one, and is a no-op if
// the flag is already present. POSIX getenv returns the first match, so editing
// in place rather than appending a second NODE_OPTIONS matters.
func suppressUndiciProxyWarning(env []string) []string {
hasProxyFlag := false
for _, kv := range env {
if strings.HasPrefix(kv, "NODE_USE_ENV_PROXY=") {
hasProxyFlag = true
break
}
}
if !hasProxyFlag {
return env
}
const key = "NODE_OPTIONS="
for i, kv := range env {
if !strings.HasPrefix(kv, key) {
continue
}
existing := kv[len(key):]
if strings.Contains(existing, undiciWarningFlag) {
return env
}
if existing == "" {
env[i] = key + undiciWarningFlag
} else {
env[i] = key + existing + " " + undiciWarningFlag
}
return env
}
return append(env, key+undiciWarningFlag)
}

// proxyEnvKeys are the proxy URL env vars (both casings) the gateway sets.
var proxyEnvKeys = []string{"HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"}

Expand Down
4 changes: 3 additions & 1 deletion cmd/onecli/run_enforce.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ package main
// one network path, a loopback forwarder that injects the gateway
// credentials (see run_enforce_forwarder.go). Direct dials fail at the OS.
//
// Currently Claude Code only. Its sandbox honors --settings for the
// This file is the NATIVE enforce path, used for agents whose runtime has
// an OS-level sandbox with a custom-proxy setting (currently Claude Code).
// Its sandbox honors --settings for the
// sandbox.* keys, denies writes to every settings.json scope from inside
// the sandbox, and (with allowUnsandboxedCommands=false) ignores the
// dangerouslyDisableSandbox escape hatch — the exact three properties
Expand Down
113 changes: 113 additions & 0 deletions cmd/onecli/run_enforce_wrap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package main

// Enforce-wrap mode: the agent-agnostic half of `onecli run --enforce`.
//
// The native path (run_enforce.go) borrows Claude Code's own sandbox and
// therefore only ever covers Claude Code — and only its Bash tool. The
// wrap path inverts ownership: onecli brings its own OS sandbox and puts
// the ENTIRE agent process tree inside it, so any agent (Codex, Cursor,
// Gemini, a bare shell) gets the same guarantee, including its own model
// API calls and every subprocess it spawns.
//
// The OS mechanism lives in internal/sandbox, selected by build tag
// (Seatbelt today; Linux planned). This file holds only what is NOT
// OS-specific: repointing the proxy env at the loopback forwarder, and
// the per-agent quirks table. The profile itself deliberately has ONE
// definition, in that package: two copies of a security policy is how the
// docker.sock rule stayed broken while a text assertion kept passing.

import (
"net"
"net/url"
"strconv"

"github.com/onecli/onecli-cli/internal/sandbox"
)

// rewriteProxyEnvToLoopback repoints every proxy URL in env at the
// loopback forwarder, preserving scheme and credentials. Under the wrap
// the sandbox blocks a direct dial to the gateway host, so the proxy env
// (and everything derived from it: Codex's proxy_url TOML, Electron
// settings) must route through the forwarder. Credentials are kept
// intact — the forwarder overrides them anyway, and Codex's config
// refresh logic keys on the embedded aoc_ marker.
func rewriteProxyEnvToLoopback(env map[string]string, port uint16) {
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(int(port)))
for _, k := range proxyEnvKeys {
v := env[k]
if v == "" {
continue
}
u, err := url.Parse(v)
if err != nil {
continue
}
u.Host = addr
env[k] = u.String()
}
}

// enforceWrapQuirkArgs returns extra argv appended for agents whose own
// runtime needs adjusting to live inside the wrap. Appended last so it
// wins over defaults (and is visible in ps for auditability).
func enforceWrapQuirkArgs(framework string) []string {
if framework == "codex" {
// Codex's internal Seatbelt (sandbox_apply) gets EPERM inside an
// outer Seatbelt profile, which breaks every shell command it
// runs. Disable its inner sandbox: network governance moves to
// the OneCLI sandbox (strictly stronger — whole process tree),
// filesystem confinement falls back to Codex's approval flow
// plus the deferred-egress denies in the profile.
return []string{"-c", "sandbox_mode=danger-full-access"}
}
return nil
}

// enforceWrapNotice returns the agent-specific stderr notice for quirks
// that change the agent's own behavior, or "".
func enforceWrapNotice(framework string) string {
if framework == "codex" {
return "onecli: Codex's internal sandbox is disabled under enforce — the OneCLI sandbox governs all egress instead; filesystem safety falls back to Codex approvals."
}
return ""
}

// enforceWrapArgv builds the argv that runs the agent confined: the
// launcher's argv with per-agent quirk flags appended last.
func enforceWrapArgv(profilePath, binary string, agentArgs []string, framework string) []string {
args := append(append([]string{}, agentArgs...), enforceWrapQuirkArgs(framework)...)
return sandbox.WrapArgv(profilePath, binary, args)
}

// enforceWrapLauncher resolves the binary to exec for wrap mode.
func enforceWrapLauncher() (string, error) {
if err := sandbox.Available(); err != nil {
return "", err
}
return sandbox.LauncherPath(), nil
}

// resolveEnforceWrap prepares wrap mode: verifies platform support,
// spawns the loopback forwarder, repoints the proxy env at it, and
// materializes the sandbox profile. Returns the profile handle and the
// forwarder port. Must run BEFORE the child env is built — everything
// derived from cfg.Env has to carry the loopback URL. Fails closed, like
// the native path.
func resolveEnforceWrap(env map[string]string) (profilePath string, port uint16, err error) {
if err := sandbox.Available(); err != nil {
return "", 0, err
}
port, err = spawnEnforceForwarder(firstProxyURL(env))
if err != nil {
return "", 0, err
}
rewriteProxyEnvToLoopback(env, port)
// The profile is rendered AFTER the forwarder exists: its network allow
// is scoped to that exact port, which is what keeps the host's LAN
// interfaces out of reach.
profilePath, err = sandbox.Materialize(port)
if err != nil {
return "", 0, err
}
return profilePath, port, nil
}
103 changes: 103 additions & 0 deletions cmd/onecli/run_enforce_wrap_bypass_live_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//go:build darwin

package main

// Live bypass suite for the enforce-wrap sandbox.
//
// The base live test (run_enforce_wrap_live_test.go) proves the happy
// path: loopback works, a direct dial doesn't. This file attacks the
// guarantee instead, by running the same escape matrix that
// `onecli sandbox audit` exposes to users (sandboxProbes in
// sandbox_audit.go). One table, two consumers: a vector added for users
// is automatically enforced in CI, so the shipped audit and the test
// suite can never drift apart.
//
// Every mustBlock vector here is an escape that env-var-only
// "enforcement" would miss — none of them read HTTPS_PROXY.

import (
"os"
"strings"
"testing"

"github.com/onecli/onecli-cli/internal/sandbox"
)

func TestLiveEnforceWrapBypasses(t *testing.T) {
if err := sandbox.Available(); err != nil {
t.Skipf("sandbox unavailable: %v", err)
}
// Port 1: nothing listens there, so every probe destination is outside
// the allow. The forwarder-reachable probe is skipped by sandboxProbes()
// when no real port is supplied.
profile, err := sandbox.Materialize(1)
if err != nil {
t.Fatalf("materializing profile: %v", err)
}

for _, p := range sandboxProbes() {
t.Run(p.name, func(t *testing.T) {
res := runSandboxProbe(profile, p)
if res.skipped {
t.Skip(res.detail)
}
if !res.held {
if p.want == mustBlock {
t.Fatalf("SANDBOX HOLE: %s\nwhy it matters: %s", res.detail, p.why)
}
t.Fatalf("profile breaks a required capability: %s\nwhy it matters: %s", res.detail, p.why)
}
})
}
}

// TestLiveSandboxAuditDetectsAHole is the audit's own regression test:
// it re-introduces the exact bug we shipped — denying the docker socket
// by its well-known path instead of its resolved one — and asserts the
// audit reports a hole.
//
// Without this, the audit could silently degrade into a test that always
// passes (a mis-typed rule name, a probe whose binary moved), and a
// green "no bypasses found" would be indistinguishable from a broken
// harness. A safety check nobody has ever seen fail is not evidence.
func TestLiveSandboxAuditDetectsAHole(t *testing.T) {
if err := sandbox.Available(); err != nil {
t.Skipf("sandbox unavailable: %v", err)
}
if _, err := os.Stat("/var/run/docker.sock"); err != nil {
t.Skip("docker socket not present — this host cannot exercise the vector")
}

// The pre-fix profile: path-literal denies that never match, because
// Seatbelt resolves the symlink first.
base := sandbox.Profile(1)
if base == "" {
t.Skip("no profile available on this host")
}
holed := strings.Replace(base,
`(deny network-outbound (regex #"docker\.sock$"))`,
`(deny network-outbound (remote unix-socket (path-literal "/var/run/docker.sock")))`,
1)
if holed == base {
t.Fatal("profile no longer contains the docker regex deny — update this test to match the current rule")
}

path := t.TempDir() + "/holed.sb"
if err := os.WriteFile(path, []byte(holed), 0o600); err != nil {
t.Fatal(err)
}

var docker sandboxProbe
for _, p := range sandboxProbes() {
if p.name == "docker-socket" {
docker = p
}
}
if docker.name == "" {
t.Fatal("docker-socket probe missing from the escape matrix")
}

if res := runSandboxProbe(path, docker); res.held {
t.Fatalf("audit did NOT detect a known hole — the harness is not actually testing anything: %s", res.detail)
}
}
Loading
Loading