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
33 changes: 24 additions & 9 deletions cmd/harnesscli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,16 @@ var (
)

type runCreateRequest struct {
Prompt string `json:"prompt"`
Model string `json:"model,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
AgentIntent string `json:"agent_intent,omitempty"`
TaskContext string `json:"task_context,omitempty"`
PromptProfile string `json:"prompt_profile,omitempty"`
PromptExtensions *runCreatePromptSettings `json:"prompt_extensions,omitempty"`
WorkspacePath string `json:"workspace_path,omitempty"`
PlanMode bool `json:"plan_mode,omitempty"`
Prompt string `json:"prompt"`
Model string `json:"model,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
AgentIntent string `json:"agent_intent,omitempty"`
TaskContext string `json:"task_context,omitempty"`
PromptProfile string `json:"prompt_profile,omitempty"`
PromptExtensions *runCreatePromptSettings `json:"prompt_extensions,omitempty"`
WorkspacePath string `json:"workspace_path,omitempty"`
PlanMode bool `json:"plan_mode,omitempty"`
Permissions *harness.PermissionConfig `json:"permissions,omitempty"`
}

type runCreatePromptSettings struct {
Expand Down Expand Up @@ -153,6 +154,8 @@ func run(args []string) int {
promptCustom := flags.String("prompt-custom", "", "custom prompt extension text")
workspace := flags.String("workspace", "", "workspace directory for this run (defaults to current working directory)")
planMode := flags.Bool("plan-mode", false, "start the run in enforced read-only plan mode")
sandbox := flags.String("sandbox", "", "sandbox scope for this run: workspace, local, or unrestricted (default: server default)")
network := flags.String("network", "", "network policy for the bash sandbox: allow or deny (default: server default, currently allow)")
enableTUI := flags.Bool("tui", false, "launch interactive BubbleTea TUI (experimental)")
resume := flags.String("resume", "", "resume an existing conversation by ID in the TUI (implies --tui)")
listProfiles := flags.Bool("list-profiles", false, "list available profiles and exit")
Expand Down Expand Up @@ -194,6 +197,17 @@ func run(args []string) int {
}
}

// permissions is populated only when the caller sets --sandbox and/or
// --network; leaving both flags unset omits "permissions" from the
// request body entirely, so the server's own defaults apply (issue #1397).
var permissions *harness.PermissionConfig
if strings.TrimSpace(*sandbox) != "" || strings.TrimSpace(*network) != "" {
permissions = &harness.PermissionConfig{
Sandbox: harness.SandboxScope(*sandbox),
Network: harness.NetworkPolicy(*network),
}
}

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

Expand All @@ -207,6 +221,7 @@ func run(args []string) int {
PromptExtensions: extensions,
WorkspacePath: workspacePath,
PlanMode: *planMode,
Permissions: permissions,
})
if err != nil {
fmt.Fprintf(stderr, "harnesscli: start run: %v\n", err)
Expand Down
143 changes: 143 additions & 0 deletions cmd/harnesscli/main_permissions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package main

import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)

// TestRunParsesSandboxAndNetworkFlagsIntoPermissions verifies that --sandbox
// and --network populate a "permissions" object on the run-create request
// body (issue #1397). When neither flag is set, "permissions" must be
// entirely absent so the server falls back to its own defaults.
func TestRunParsesSandboxAndNetworkFlagsIntoPermissions(t *testing.T) {
var rawBody []byte
mux := http.NewServeMux()
mux.HandleFunc("/v1/runs", func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
rawBody = body
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_, _ = io.WriteString(w, `{"run_id":"run_perm","status":"queued"}`)
})
mux.HandleFunc("/v1/runs/run_perm/events", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
_, _ = io.WriteString(w, "event: run.completed\n")
_, _ = io.WriteString(w, "data: {\"id\":\"e1\",\"run_id\":\"run_perm\",\"type\":\"run.completed\"}\n\n")
})
ts := httptest.NewServer(mux)
defer ts.Close()

origRequestClient := requestHTTPClient
origStreamClient := streamHTTPClient
origStdout := stdout
origStderr := stderr
defer func() {
requestHTTPClient = origRequestClient
streamHTTPClient = origStreamClient
stdout = origStdout
stderr = origStderr
}()

requestHTTPClient = ts.Client()
streamHTTPClient = ts.Client()
stdout = &bytes.Buffer{}
stderr = &bytes.Buffer{}

code := run([]string{
"-base-url=" + ts.URL,
"-prompt=do work",
"-sandbox=local",
"-network=deny",
})
if code != 0 {
t.Fatalf("expected exit code 0, got %d", code)
}

var body map[string]any
if err := json.Unmarshal(rawBody, &body); err != nil {
t.Fatalf("decode captured request body: %v; raw=%s", err, rawBody)
}
permsRaw, ok := body["permissions"]
if !ok {
t.Fatalf("expected \"permissions\" key in request body, got: %s", rawBody)
}
perms, ok := permsRaw.(map[string]any)
if !ok {
t.Fatalf("expected \"permissions\" to be an object, got: %T (%v)", permsRaw, permsRaw)
}
if perms["sandbox"] != "local" {
t.Errorf("expected permissions.sandbox=%q, got %v", "local", perms["sandbox"])
}
if perms["network"] != "deny" {
t.Errorf("expected permissions.network=%q, got %v", "deny", perms["network"])
}
if approval, ok := perms["approval"]; !ok || approval != "" {
t.Errorf("expected permissions.approval to be present and empty (matching the struct's json tag), got %v (present=%v)", approval, ok)
}
}

// TestRunOmitsPermissionsWhenNoSandboxOrNetworkFlagSet verifies that a plain
// run request (no --sandbox, no --network) sends no "permissions" field at
// all, leaving the server's own defaults in effect (issue #1397).
func TestRunOmitsPermissionsWhenNoSandboxOrNetworkFlagSet(t *testing.T) {
var rawBody []byte
mux := http.NewServeMux()
mux.HandleFunc("/v1/runs", func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
rawBody = body
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_, _ = io.WriteString(w, `{"run_id":"run_noperm","status":"queued"}`)
})
mux.HandleFunc("/v1/runs/run_noperm/events", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
_, _ = io.WriteString(w, "event: run.completed\n")
_, _ = io.WriteString(w, "data: {\"id\":\"e1\",\"run_id\":\"run_noperm\",\"type\":\"run.completed\"}\n\n")
})
ts := httptest.NewServer(mux)
defer ts.Close()

origRequestClient := requestHTTPClient
origStreamClient := streamHTTPClient
origStdout := stdout
origStderr := stderr
defer func() {
requestHTTPClient = origRequestClient
streamHTTPClient = origStreamClient
stdout = origStdout
stderr = origStderr
}()

requestHTTPClient = ts.Client()
streamHTTPClient = ts.Client()
stdout = &bytes.Buffer{}
stderr = &bytes.Buffer{}

code := run([]string{
"-base-url=" + ts.URL,
"-prompt=do work",
})
if code != 0 {
t.Fatalf("expected exit code 0, got %d", code)
}

var body map[string]any
if err := json.Unmarshal(rawBody, &body); err != nil {
t.Fatalf("decode captured request body: %v; raw=%s", err, rawBody)
}
if _, ok := body["permissions"]; ok {
t.Errorf("expected no \"permissions\" key when neither flag is set, got: %s", rawBody)
}
}
58 changes: 58 additions & 0 deletions docs/logs/engineering-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -6102,3 +6102,61 @@ Skipped creating separate issues for Op/EventMsg protocol (already covered by SS
client.go. Live verification against DeepSeek via OpenRouter is a
follow-up for whoever holds the API key; this PR only proves the
fake/unit/integration paths.

# 2026-09-06 (Issue #1397 bash sandbox network policy — SECURITY-RELEVANT DEFAULT CHANGE)

- Prior behavior: `SandboxScopeWorkspace` and `SandboxScopeLocal` unconditionally
denied outbound network from the `bash` tool (`(deny network*)` in the darwin
seatbelt profile, `--unshare-net` always in the linux bwrap invocation), with
no way for a caller to opt back in. A run that legitimately needed to install
a dependency or call an API from a sandboxed `bash` call had no way to do so
short of dropping to `SandboxScopeUnrestricted`, which also drops filesystem
confinement.
- Change: `harness.PermissionConfig` gains a third axis, `Network` (json
`network`, values `""`/`"allow"`/`"deny"`, default `"allow"`), mirrored at
the tools layer as `tools.NetworkPolicy` with a context key
(`ContextKeyNetworkPolicy`), `WithNetworkPolicy`, and
`NetworkPolicyFromContext`, set alongside the existing sandbox-scope context
value wherever the runner sets up a tool call's execution context
(`runner_step_engine.go`). **SECURITY-RELEVANT DEFAULT CHANGE:** workspace
and local sandbox scopes now allow outbound network by default; callers that
need the old always-deny behavior must set `permissions.network` to
`"deny"` explicitly. `SandboxScopeUnrestricted` is unaffected (it was never
network-confined).
- Gotcha found while implementing the darwin side: seatbelt's `(deny default)`
means every operation — including network — is denied unless explicitly
allowed. The first implementation attempt just omitted the `(deny network*)`
line for the allow case and left it there; that still left curl unable to
resolve DNS (`exit 6`) because omitting a deny rule under `(deny default)`
does not become an allow. The fix emits an explicit `(allow network*)` line
for the allow case (and an explicit `(deny network*)` for deny, which is
redundant with the default but kept for clarity/robustness against a future
default-policy change). Confirmed directly with `sandbox-exec` against a
real host (`https://proxy.golang.org`): `http_code=200` under an
`(allow network*)` profile, `exit=6` ("could not resolve host") under
`(deny network*)`.
- `SandboxExecResult` gains a `NetworkPolicy` field so the bash tool result map
carries a `sandbox_network` key reporting the policy actually applied,
independent of the mechanism (`seatbelt`, `bubblewrap`, `none`, or
`unavailable`).
- The permissions statement injected into the model's context
("Permissions for this run: sandbox=%s, approval=%s, network=%s.") now
includes the network axis and, when denied, an explicit warning that
dependency installs will fail and the model should report the blocker
rather than substitute a design. It is now appended to every turn's wire
message list (not persisted into conversation history, unlike the existing
continuation-changed notice) so it reaches the model starting on turn one —
previously this line only appeared on a continuation whose permissions
changed from the source run, never on a fresh run's first turn.
- `harnesscli` gains `--sandbox` and `--network` flags on the one-shot run
path, populating `permissions` on the run-create request only when either
flag is set (both omitted → server defaults, matching the new default
above).
- Regression: `TestRedTeam_SandboxNetwork_DefaultPermissionsAllowCurl` drives a
real bash tool call through the full runner with no explicit `Permissions`
and asserts curl is not rejected; `TestJobManagerRunForegroundReportsSandboxNetworkInResult`
asserts the `sandbox_network` result field matches the applied policy for
both allow and deny under a real OS-level sandbox; a live integration test
(`TestSandboxWorkspaceScopeNetworkPolicyLiveCurl`) curls
`https://proxy.golang.org` through the actual seatbelt sandbox and asserts
success under allow, failure under deny.
21 changes: 14 additions & 7 deletions internal/harness/clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,17 +82,24 @@ func copyMessages(msgs []Message) []Message {
// conversation history — so the provider-visible prefix (tools + system +
// history) only ever grows by appending and stays a valid cached prefix across
// steps. All volatile per-step content (working memory, observational memory,
// dynamic rules, plan-mode guidance, and the runtime context, which carries the
// step number, token and cost totals, and a timestamp) is placed at the tail,
// after the history, where it can change every step without invalidating the
// cached prefix. Empty snippets are skipped.
func (r *Runner) buildTurnMessages(systemPrompt string, messages []Message, workingMemory, observationalMemory, ruleContent, planModeGuidance, runtimeContext string) []Message {
tm := make([]Message, 0, len(messages)+6)
// dynamic rules, plan-mode guidance, the permissions notice, and the runtime
// context, which carries the step number, token and cost totals, and a
// timestamp) is placed at the tail, after the history, where it can change
// every step without invalidating the cached prefix. Empty snippets are
// skipped.
//
// permissionsNotice reports the run's sandbox/approval/network policy
// (issue #1397). It is recomputed and re-appended every turn rather than
// persisted into conversation history, so it always reaches the model
// (including turn one) without inflating the stored transcript the way a
// history message would.
func (r *Runner) buildTurnMessages(systemPrompt string, messages []Message, workingMemory, observationalMemory, ruleContent, planModeGuidance, permissionsNotice, runtimeContext string) []Message {
tm := make([]Message, 0, len(messages)+7)
if systemPrompt != "" {
tm = append(tm, Message{Role: "system", Content: systemPrompt})
}
tm = append(tm, copyMessages(messages)...)
for _, tail := range []string{workingMemory, observationalMemory, ruleContent, planModeGuidance, runtimeContext} {
for _, tail := range []string{workingMemory, observationalMemory, ruleContent, planModeGuidance, permissionsNotice, runtimeContext} {
if strings.TrimSpace(tail) != "" {
tm = append(tm, Message{Role: "system", Content: tail})
}
Expand Down
41 changes: 41 additions & 0 deletions internal/harness/permission_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,47 @@ func TestPermissionConfigValidation(t *testing.T) {
}
}

// TestPermissionConfigNetworkValidation checks that ValidatePermissionConfig
// accepts the empty/allow/deny NetworkPolicy values and rejects anything
// else (issue #1397).
func TestPermissionConfigNetworkValidation(t *testing.T) {
t.Parallel()

valid := []PermissionConfig{
{Sandbox: SandboxScopeWorkspace, Approval: ApprovalPolicyNone, Network: ""},
{Sandbox: SandboxScopeWorkspace, Approval: ApprovalPolicyNone, Network: NetworkPolicyAllow},
{Sandbox: SandboxScopeWorkspace, Approval: ApprovalPolicyNone, Network: NetworkPolicyDeny},
}
for _, cfg := range valid {
if err := ValidatePermissionConfig(cfg); err != nil {
t.Errorf("expected valid network %q to pass, got: %v", cfg.Network, err)
}
}

badCfg := PermissionConfig{Sandbox: SandboxScopeWorkspace, Approval: ApprovalPolicyNone, Network: "maybe"}
if err := ValidatePermissionConfig(badCfg); err == nil {
t.Error("expected invalid network \"maybe\" to fail, but got nil error")
}
}

// TestNormalizePermissionConfigDefaultsNetworkToAllow verifies that an empty
// Network field normalizes to NetworkPolicyAllow, matching the safety-biased
// default documented on PermissionConfig (issue #1397): workspace/local
// scopes allow outbound network unless a caller explicitly opts into deny.
func TestNormalizePermissionConfigDefaultsNetworkToAllow(t *testing.T) {
t.Parallel()

got := normalizePermissionConfig(PermissionConfig{Sandbox: SandboxScopeWorkspace, Approval: ApprovalPolicyNone})
if got.Network != NetworkPolicyAllow {
t.Errorf("expected normalized empty network to default to %q, got %q", NetworkPolicyAllow, got.Network)
}

got = normalizePermissionConfig(PermissionConfig{Sandbox: SandboxScopeWorkspace, Approval: ApprovalPolicyNone, Network: NetworkPolicyDeny})
if got.Network != NetworkPolicyDeny {
t.Errorf("expected explicit deny to be preserved, got %q", got.Network)
}
}

// TestPermissionConfigToLegacy verifies backward-compatible mapping from
// PermissionConfig to the legacy ToolApprovalMode.
func TestPermissionConfigToLegacy(t *testing.T) {
Expand Down
Loading
Loading