From 87ee4435a1003be782e968b52a5cc28315709112 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 21 Sep 2026 16:09:31 +0800 Subject: [PATCH] Support restricted network policies for hosted environment templates --- CONTRIBUTING.md | 54 ++++-- .../internal/agent/claudesdk/local.go | 8 +- .../internal/agent/claudesdk/local_test.go | 18 ++ .../internal/agent/claudesdk/workspace.go | 9 +- .../internal/agent/codex/environment_local.go | 2 +- .../internal/agent/codex/managed_network.go | 113 ++++++++++++ .../agent/codex/managed_network_live_test.go | 111 +++++++++++ .../agent/codex/managed_network_test.go | 71 ++++++++ .../internal/agent/codex/options.go | 2 + .../agent/codex/permission_profile.go | 14 +- .../agent/codex/permission_profile_test.go | 3 +- .../internal/agent/codex/preparation.go | 6 + .../internal/agent/codex/session.go | 28 +-- .../internal/agent/codex/session_plan.go | 6 + .../internal/agent/mcode/workspace.go | 12 +- .../agent/mcode/workspace_network_test.go | 38 ++++ apps/parsar-daemon/internal/cli/claude_sdk.go | 2 +- .../internal/cli/mcode_workspace.go | 2 +- .../internal/localworkspace/binding.go | 34 ++-- .../internal/localworkspace/binding_test.go | 3 +- .../internal/localworkspace/network.go | 32 ++++ .../localworkspace/network_policy_test.go | 51 +++++- contracts/agents-api/README.md | 4 +- contracts/agents-api/environment-templates.md | 70 ++++++- contracts/agents-api/environments.md | 13 +- contracts/agents-api/openapi.yaml | 14 +- internal/agentdaemon/proto/environment.go | 3 +- internal/agentnetwork/policy.go | 95 ++++++++++ internal/agentnetwork/policy_test.go | 60 ++++++ packages/claude-sdk-adapter/src/workspace.ts | 15 +- .../tests/workspace.test.mjs | 9 + packages/mcode-harness/launch.mjs | 10 +- scripts/build-agents-api.sh | 2 +- services/agents-api/deploy/codex/README.md | 7 +- .../internal/api/environment_network_test.go | 94 ++++++++++ .../internal/api/environment_templates.go | 7 +- .../api/environment_templates_test.go | 7 +- services/agents-api/internal/api/handler.go | 2 +- .../internal/api/hosted_environment.go | 6 +- .../internal/api/session_template.go | 7 +- .../db/queries/environment_templates.sql | 11 +- .../db/sqlc/environment_templates.sql.go | 172 ++++++++++-------- .../agents-api/internal/db/sqlc/models.go | 27 +-- .../execution/environment_placement.go | 21 +-- .../execution/environment_placement_test.go | 18 ++ .../internal/execution/runtime_lifecycle.go | 2 +- .../agents-api/internal/execution/support.go | 2 +- .../internal/sandbox/docker/provider.go | 11 +- .../internal/sandbox/docker/provider_test.go | 28 ++- .../internal/sandbox/docker/recovery_test.go | 2 +- .../agents-api/internal/sandbox/provider.go | 1 + .../store/environment_network_test.go | 58 ++++++ .../internal/store/environment_templates.go | 12 +- .../internal/store/initial_files.go | 2 +- .../store/local_environment_worker_test.go | 1 + .../000047_environment_network_domains.sql | 21 +++ .../tests/official_environment_network.py | 74 ++++++++ .../tests/official_environment_templates.py | 5 +- 58 files changed, 1288 insertions(+), 224 deletions(-) create mode 100644 apps/parsar-daemon/internal/agent/codex/managed_network.go create mode 100644 apps/parsar-daemon/internal/agent/codex/managed_network_live_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/managed_network_test.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/workspace_network_test.go create mode 100644 apps/parsar-daemon/internal/localworkspace/network.go create mode 100644 internal/agentnetwork/policy.go create mode 100644 internal/agentnetwork/policy_test.go create mode 100644 services/agents-api/internal/api/environment_network_test.go create mode 100644 services/agents-api/internal/store/environment_network_test.go create mode 100644 services/agents-api/migrations/000047_environment_network_domains.sql create mode 100644 services/agents-api/tests/official_environment_network.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a2fff49af..90aa133bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -283,7 +283,7 @@ unqualified. Skills API references, generic Plugins and capability-directory imports remain separate work; an adapter-owned Claude plugin envelope does not implement public Plugins. -Name, enabled/disabled network, initial files, inline Skills and env/setup/system/npm/Python are +Name, enabled/disabled/exact-domain restricted network, initial files, inline Skills and env/setup/system/npm/Python are implemented independently of remaining installation fields. Reject unsupported inputs rather than persisting them for silent omission; expand inline and template initialization together in separately qualified @@ -291,13 +291,29 @@ batches. Resource reads need only tenant authorization, not a live Runtime. See the [Template coverage and unresolved semantics](contracts/agents-api/environment-templates.md). SandboxProvider has five operations: Create, GetInfo, Renew, Kill and RunCommand. -Use maintained provider SDKs and thin adapters, Docker first and E2B after the MVP. +Use maintained provider SDKs and thin adapters. The current hosted offering uses Docker. Provider initialization creates the sandbox and starts its daemon/harness; RunCommand is for initialization only. Daily execution and Files use Runtime and native or bounded local capabilities. Docker's lack of a native renewable lease does not remove service-owned hosted expiry and cleanup requirements. -The E2B Provider uses an explicit `templateID:build_UUID` and the same qualified +The official `openai_hosted` discriminator means hosting by this independent Core +service, using Docker V1. Keep the public value unchanged; `parsar_hosted` is not +a new API type. Public Environment Templates apply only to this hosted path. +E2B onboarding follows the official `self_hosted` workflow: an application or +webhook controller owns sandbox provisioning and cleanup, and the executor connects +with the returned Environment ID, unchanged `remote_url` and scoped environment +authorization. Reuse existing Runtime and provider components without a separate +public integration design. A private daemon connection alone is not evidence of +official interoperability. Qualify tenant ownership, credentials and connection +lifecycle using the pinned client and actual execution. + +The previously accepted Core-managed E2B route remains implementation evidence +pending bounded realignment and obsolete-route cleanup after Environment Templates. +Do not expand it as a second hosted offering. Current Template acceptance uses +Docker; historical E2B tests retain only their demonstrated scope. + +The existing E2B Provider uses an explicit `templateID:build_UUID` and the same qualified colocated Runtime. Its root-private bootstrap input and final atomic receipt live on persistent disk, never template `/run`. Running compute alone does not establish completed initialization. Inspect exact installation/tenant/Environment/allocation @@ -407,14 +423,26 @@ The [co-location qualification inputs](services/agents-api/deploy/codex/README.m record the pinned native/Docker prerequisites and limits; this switch alone does not admit hosted Environments or authorize a workspace. -A managed Runtime's enabled/disabled network policy is immutable deployment input, -transferred through the provider-neutral bootstrap and checked against execution -preparation. The native adapter selects the corresponding managed profile; Core -and Docker do not select native profile names. New policy-aware peers advertise -`local_environment_network_policy`; enabled execution requires that capability. -The older explicit-disabled internal peer path remains supported without widening -its policy. Read-only workspace access does not require execution network policy. -A declaration alone does not qualify an image or admit public hosted creation. +A managed Runtime's network policy is immutable deployment input, transferred +through the provider-neutral bootstrap and checked against execution preparation. +The shared policy includes enabled, disabled and an exact-host restricted allowlist. +Core preserves public spelling/order/duplicates and only permits Template overrides +that narrow authority. Adapters translate a normalized copy into native settings; +Core and Docker never select native profile names. Every hosted execution peer +must support `local_environment_network_policy` and receive the complete bound +policy; missing policy never falls back to enabled or an older peer path. Read-only +workspace access does not require execution network policy. A declaration alone +does not qualify an image or admit public hosted creation. + +Codex restricted networking uses its native managed network requirements and proxy. +Its adapter preserves the image's filesystem, approval and hook requirements and +adds the frozen exact-host ceiling in Session-private state. The existing RPC +client owns a bubblewrap child that mounts those requirements read-only and runs +the stock native app-server in a PID namespace; teardown retains the same owner. +Preparation regenerates the non-secret requirements from the frozen policy. Keep +the file with Session state so cleanup cannot race a running child's mount. +Claude and MiniMax translate the same policy into their native sandbox allowlists. +These translations do not add a Core network service or model/tool loop. A dedicated local Runtime uses one Environment-scoped device credential and an immutable binding to that Environment's Session. It is excluded from general @@ -435,8 +463,8 @@ establish Provider lifecycle, or define the official `self_hosted` mapping. Core rechecks the persisted Environment/device binding for preparation and active reads; capability discovery cannot select or authorize a general device for this placement. Local work uses the existing pending-input reservation and Worker -ownership without a remote connection resolver. The basic hosted profile supports `network.access: enabled` or `disabled`; the -actual image must qualify both native profiles before public deployment. Omitted +ownership without a remote connection resolver. The hosted profile supports `network.access: enabled`, `disabled` and exact-host +`restricted`; each image must qualify the supported policies before public deployment. Omitted network settings mean enabled upstream and must not be silently treated as disabled. Core and Runtime use common preparation, start, input-receipt, cancellation, diff --git a/apps/parsar-daemon/internal/agent/claudesdk/local.go b/apps/parsar-daemon/internal/agent/claudesdk/local.go index e119a826c..d601a7a29 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/local.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/local.go @@ -5,20 +5,22 @@ import ( "os" "path/filepath" "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" ) // ConfigureLocal selects the qualified, dedicated Runtime layout. The shared // localworkspace binding still authorizes every request against its Session. -func ConfigureLocal(config Config, root, workspace, network, staging string) (Config, error) { +func ConfigureLocal(config Config, root, workspace string, network agentnetwork.Policy, staging string) (Config, error) { config.StateDir = filepath.Join(root, "runtime", "claude-sdk", "history") config.Workspace = &WorkspaceConfig{ - Directory: workspace, PublicDirectory: "/workspace", NetworkAccess: network, + Directory: workspace, PublicDirectory: "/workspace", NetworkAccess: network.Access, AllowedDomains: network.Hosts(), HomeDir: filepath.Join(root, "runtime", "claude-sdk", "home"), ScratchDir: filepath.Join(root, "runtime", "claude-sdk", "scratch"), ProtectedDirs: []string{filepath.Join(root, "parsar-daemon"), staging}, DependencyPath: "/usr/local/bin:/usr/bin:/bin", } - if network != "enabled" && network != "disabled" { + if network.Validate() != nil { return Config{}, fmt.Errorf("claudesdk: dedicated Runtime requires an explicit network policy") } for _, dir := range []string{config.StateDir, config.Workspace.HomeDir, config.Workspace.ScratchDir} { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/local_test.go b/apps/parsar-daemon/internal/agent/claudesdk/local_test.go index 9ad724151..51c7ca7b0 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/local_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/local_test.go @@ -41,6 +41,24 @@ func TestLocalWorkspaceBindingNetworkAndRequiredHistory(t *testing.T) { } } +func TestRestrictedWorkspacePolicyUsesExactBoundAuthority(t *testing.T) { + config := workspaceFixture(t) + config.Workspace.NetworkAccess = "restricted" + config.Workspace.AllowedDomains = []string{"Example.com", "api.example.com", "example.com"} + req := workspaceRequest() + req.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "restricted", AllowedDomains: []string{"api.example.com", "EXAMPLE.COM"}} + start, _, err := prepare(config, req) + if err != nil || !slices.Equal(start.Workspace.AllowedDomains, []string{"api.example.com", "example.com"}) { + t.Fatal("native policy lost exact bound domains", start, err) + } + for _, domains := range [][]string{{"example.com"}, {"example.org"}, nil} { + req.LocalEnvironment.AllowedDomains = domains + if _, _, err := prepare(config, req); err == nil { + t.Fatal("different policy entered bound Runtime", domains) + } + } +} + func TestWorkspaceProviderCredentialsReplaceAmbientSelection(t *testing.T) { config := workspaceFixture(t) original := slices.Clone(config.Env) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go index 3be299d92..1a0a45d16 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go @@ -9,6 +9,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" ) @@ -21,6 +22,7 @@ type WorkspaceConfig struct { Directory string PublicDirectory string NetworkAccess string + AllowedDomains []string HomeDir string ScratchDir string ProtectedDirs []string @@ -38,6 +40,7 @@ type workspaceProfile struct { DependencyPath string `json:"dependency_path"` EnvNames []string `json:"env_names"` NetworkAccess string `json:"network_access,omitempty"` + AllowedDomains []string `json:"allowed_domains,omitempty"` } func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspaceProfile, []string, error) { @@ -47,7 +50,7 @@ func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspace if req.WorkDir != "" && req.WorkDir != config.Workspace.Directory { return nil, nil, fmt.Errorf("claudesdk: work_dir conflicts with the trusted workspace binding") } - if req.LocalEnvironment != nil && (config.Workspace.NetworkAccess == "" || req.LocalEnvironment.NetworkAccess != config.Workspace.NetworkAccess) { + if req.LocalEnvironment != nil && !(agentnetwork.Policy{Access: config.Workspace.NetworkAccess, AllowedDomains: config.Workspace.AllowedDomains}).Equal(agentnetwork.Policy{Access: req.LocalEnvironment.NetworkAccess, AllowedDomains: req.LocalEnvironment.AllowedDomains}) { return nil, nil, fmt.Errorf("claudesdk: local Runtime network policy mismatch") } profile, env, err := workspaceEnvironment(config) @@ -87,7 +90,7 @@ func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { if w == nil || !filepath.IsAbs(config.Node) || !filepath.IsAbs(config.Entrypoint) { return fail() } - if w.NetworkAccess != "" && w.NetworkAccess != "disabled" && w.NetworkAccess != "enabled" { + if (w.NetworkAccess != "" || len(w.AllowedDomains) > 0) && (agentnetwork.Policy{Access: w.NetworkAccess, AllowedDomains: w.AllowedDomains}).Validate() != nil { return fail() } if w.PublicDirectory != "" { @@ -157,7 +160,7 @@ func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { } dependencyPath := strings.Join(dependencies, string(os.PathListSeparator)) profile := &workspaceProfile{Home: w.HomeDir, State: config.StateDir, Scratch: w.ScratchDir, - ProtectedDirs: append([]string{}, w.ProtectedDirs...), DependencyPath: dependencyPath, EnvNames: []string{}, NetworkAccess: w.NetworkAccess} + ProtectedDirs: append([]string{}, w.ProtectedDirs...), DependencyPath: dependencyPath, EnvNames: []string{}, NetworkAccess: w.NetworkAccess, AllowedDomains: (agentnetwork.Policy{Access: w.NetworkAccess, AllowedDomains: w.AllowedDomains}).Hosts()} env := []string{"PATH=" + dependencyPath, "HOME=" + w.HomeDir, "TMPDIR=" + w.ScratchDir, "CLAUDE_CONFIG_DIR=" + config.StateDir, "DISABLE_TELEMETRY=1", "DISABLE_ERROR_REPORTING=1", "DISABLE_AUTOUPDATER=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1"} diff --git a/apps/parsar-daemon/internal/agent/codex/environment_local.go b/apps/parsar-daemon/internal/agent/codex/environment_local.go index 6ca9c1ce1..415106ed3 100644 --- a/apps/parsar-daemon/internal/agent/codex/environment_local.go +++ b/apps/parsar-daemon/internal/agent/codex/environment_local.go @@ -23,5 +23,5 @@ func SupportsLocalNetworkPolicy(version string) bool { return false } binding, err := localworkspace.Load() - return err == nil && binding != nil && binding.NetworkAccess() != "" + return err == nil && binding != nil && binding.NetworkPolicy().Validate() == nil } diff --git a/apps/parsar-daemon/internal/agent/codex/managed_network.go b/apps/parsar-daemon/internal/agent/codex/managed_network.go new file mode 100644 index 000000000..2fad0c583 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/managed_network.go @@ -0,0 +1,113 @@ +package codex + +import ( + "bytes" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/BurntSushi/toml" + "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" +) + +const nativeManagedRequirements = "/etc/codex/requirements.toml" + +// Preserve the image's native filesystem, approval and hook requirements. This +// adapter adds the exact network ceiling; Core never supplies native TOML. +func managedNetworkRequirements(base []byte, policy agentnetwork.Policy) ([]byte, error) { + if policy.Access != "restricted" || policy.Validate() != nil { + return nil, errors.New("codex: invalid restricted network policy") + } + var config map[string]any + if _, err := toml.Decode(string(base), &config); err != nil { + return nil, err + } + if _, exists := config["experimental_network"]; exists { + return nil, errors.New("codex: image already defines managed network requirements") + } + domains := make(map[string]string) + for _, host := range policy.Hosts() { + domains[host] = "allow" + } + var addition bytes.Buffer + err := toml.NewEncoder(&addition).Encode(map[string]any{"experimental_network": map[string]any{ + "enabled": true, "managed_allowed_domains_only": true, + "allow_upstream_proxy": false, "dangerously_allow_all_unix_sockets": false, + "allow_local_binding": false, "domains": domains, + }}) + if err != nil { + return nil, err + } + result := append(append([]byte{}, base...), '\n') + return append(result, addition.Bytes()...), nil +} + +func prepareManagedNetwork(plan *SessionPlan, policy agentnetwork.Policy) error { + var home string + for _, entry := range plan.Env { + if value, found := strings.CutPrefix(entry, "CODEX_HOME="); found { + home = value + } + } + if !filepath.IsAbs(home) { + return errors.New("codex: managed network requires private Session state") + } + base, err := os.ReadFile(nativeManagedRequirements) + if err != nil { + return err + } + contents, err := managedNetworkRequirements(base, policy) + if err != nil { + return err + } + file, err := os.CreateTemp(home, ".managed-network-*.toml") + if err != nil { + return err + } + defer os.Remove(file.Name()) + if _, err = file.Write(contents); err == nil { + err = file.Chmod(0400) + } + closeErr := file.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + path := filepath.Join(home, "managed-network.toml") + if err = os.Rename(file.Name(), path); err != nil { + return err + } + // Retain this non-secret file with Session state. Rebuild it from the frozen + // policy on preparation; cleanup never races a process still holding its mount. + plan.managedRequirements = path + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.network_proxy", "true"}) + return nil +} + +// The existing RPC client owns the sole child, its pipes and teardown. The PID +// namespace terminates native descendants when that owned process is killed. +func configureManagedNetworkProcess(cfg *JSONRPCConfig, requirements string) error { + if requirements == "" { + return nil + } + if !filepath.IsAbs(requirements) { + return errors.New("codex: managed requirements path must be absolute") + } + binary, err := exec.LookPath(cfg.Binary) + if err != nil { + return err + } + binary, err = filepath.Abs(binary) + if err != nil { + return err + } + cfg.Binary = "/usr/bin/bwrap" + cfg.ExtraArgs = append([]string{"--die-with-parent", "--unshare-pid", "--bind", "/", "/", + "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--ro-bind", requirements, + nativeManagedRequirements, binary}, cfg.ExtraArgs...) + return nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/managed_network_live_test.go b/apps/parsar-daemon/internal/agent/codex/managed_network_live_test.go new file mode 100644 index 000000000..3fa8d4490 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/managed_network_live_test.go @@ -0,0 +1,111 @@ +//go:build linux + +package codex + +import ( + "bytes" + "context" + "os" + "path/filepath" + stdstrconv "strconv" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" +) + +// Run inside the qualified Docker Runtime. This checks the real native process +// and managed requirements, not model execution or public protocol acceptance. +func TestManagedNetworkNativeLifecycle(t *testing.T) { + if os.Getenv("PARSAR_CODEX_MANAGED_NETWORK_LIVE") != "1" { + t.Skip("requires the qualified Docker Runtime and native Codex") + } + base, err := os.ReadFile(nativeManagedRequirements) + if err != nil { + t.Fatal(err) + } + for _, mode := range []string{"close", "owner-cancel"} { + t.Run(mode, func(t *testing.T) { + home := t.TempDir() + plan := SessionPlan{Env: []string{"CODEX_HOME=" + home}} + policy := agentnetwork.Policy{Access: "restricted", AllowedDomains: []string{"Example.com"}} + if err := prepareManagedNetwork(&plan, policy); err != nil { + t.Fatal(err) + } + cfg := JSONRPCConfig{Binary: "/usr/local/bin/codex", Env: append(os.Environ(), plan.Env...)} + for _, entry := range plan.ExtraConfig { + cfg.ExtraArgs = append(cfg.ExtraArgs, "-c", entry[0]+"="+entry[1]) + } + if err := configureManagedNetworkProcess(&cfg, plan.managedRequirements); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + client := NewJSONRPCClient(cfg) + defer client.Close() + if _, err := client.Start(ctx, InitializeParams{ + ClientInfo: InitializeClientInfo{Name: "network-lifecycle-test", Version: "1"}, + Capabilities: &InitializeCapabilities{ExperimentalAPI: true}, + }); err != nil { + t.Fatal(err) + } + requirements, err := client.Request(ctx, "configRequirements/read", nil) + if err != nil || !bytes.Contains(requirements, []byte("example.com")) { + t.Fatalf("native policy was not applied: %s: %v", requirements, err) + } + owned := networkNativeProcesses(t, home) + if len(owned) == 0 { + t.Fatal("native child was not observed") + } + if mode == "owner-cancel" { + cancel() + } + if err := client.Close(); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for { + survivors := networkNativeProcesses(t, home) + if len(survivors) == 0 { + break + } + if time.Now().After(deadline) { + t.Fatalf("native processes survived %s: %v", mode, survivors) + } + time.Sleep(20 * time.Millisecond) + } + if client.Alive() || client.cmd.ProcessState == nil { + t.Fatal("RPC owner did not settle") + } + }) + } + after, err := os.ReadFile(nativeManagedRequirements) + if err != nil || !bytes.Equal(base, after) { + t.Fatal("image requirements changed", err) + } +} + +func networkNativeProcesses(t *testing.T, home string) []string { + t.Helper() + entries, err := os.ReadDir("/proc") + if err != nil { + t.Fatal(err) + } + var found []string + for _, entry := range entries { + pid, err := stdstrconv.Atoi(entry.Name()) + if err != nil || pid == os.Getpid() { + continue + } + env, err := os.ReadFile(filepath.Join("/proc", entry.Name(), "environ")) + if err == nil { + for _, value := range strings.Split(string(env), "\x00") { + if value == "CODEX_HOME="+home { + found = append(found, entry.Name()) + } + } + } + } + return found +} diff --git a/apps/parsar-daemon/internal/agent/codex/managed_network_test.go b/apps/parsar-daemon/internal/agent/codex/managed_network_test.go new file mode 100644 index 000000000..6348af028 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/managed_network_test.go @@ -0,0 +1,71 @@ +package codex + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/BurntSushi/toml" + "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" +) + +func TestManagedNetworkPreservesImageRequirements(t *testing.T) { + base, err := os.ReadFile("../../../../../services/agents-api/deploy/codex/requirements.toml") + if err != nil { + t.Fatal(err) + } + policy := agentnetwork.Policy{Access: "restricted", AllowedDomains: []string{"Example.com", "api.example.com", "example.com"}} + result, err := managedNetworkRequirements(base, policy) + if err != nil { + t.Fatal(err) + } + var original, merged map[string]any + if _, err = toml.Decode(string(base), &original); err != nil { + t.Fatal(err) + } + if _, err = toml.Decode(string(result), &merged); err != nil { + t.Fatal(err) + } + network := merged["experimental_network"].(map[string]any) + delete(merged, "experimental_network") + if !reflect.DeepEqual(merged, original) { + t.Fatal("native filesystem/approval/hooks changed") + } + for _, flag := range []string{"enabled", "managed_allowed_domains_only"} { + if network[flag] != true { + t.Fatal("missing ceiling", flag) + } + } + for _, flag := range []string{"allow_upstream_proxy", "dangerously_allow_all_unix_sockets", "allow_local_binding"} { + if network[flag] != false { + t.Fatal("network escape enabled", flag) + } + } + want := map[string]any{"example.com": "allow", "api.example.com": "allow"} + if !reflect.DeepEqual(network["domains"], want) { + t.Fatal("exact domains changed", network["domains"]) + } + if _, err := managedNetworkRequirements(result, policy); err == nil { + t.Fatal("image ceiling overwritten") + } +} + +func TestManagedNetworkWrapsTheExistingRPCCommand(t *testing.T) { + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "managed-network.toml") + cfg := JSONRPCConfig{Binary: binary, ExtraArgs: []string{"-c", "features.network_proxy=true"}, Env: []string{"HOME=/private"}, Cwd: "/workspace"} + if err := configureManagedNetworkProcess(&cfg, path); err != nil { + t.Fatal(err) + } + if cfg.Binary != "/usr/bin/bwrap" || cfg.Cwd != "/workspace" || !reflect.DeepEqual(cfg.Env, []string{"HOME=/private"}) { + t.Fatal("RPC ownership/configuration changed") + } + want := []string{"--die-with-parent", "--unshare-pid", "--bind", "/", "/", "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--ro-bind", path, nativeManagedRequirements, binary, "-c", "features.network_proxy=true"} + if !reflect.DeepEqual(cfg.ExtraArgs, want) { + t.Fatal(cfg.ExtraArgs) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/options.go b/apps/parsar-daemon/internal/agent/codex/options.go index 1f86b5788..8f71ccd4c 100644 --- a/apps/parsar-daemon/internal/agent/codex/options.go +++ b/apps/parsar-daemon/internal/agent/codex/options.go @@ -15,6 +15,8 @@ import ( // SessionPlan holds the resolved per-prompt launch plan derived from // the daemon's PromptRequestPayload. type SessionPlan struct { + managedRequirements string + // Cwd is the validated working directory passed to codex (and to // the spawned app-server). Empty when the caller provided no work_dir. Cwd string diff --git a/apps/parsar-daemon/internal/agent/codex/permission_profile.go b/apps/parsar-daemon/internal/agent/codex/permission_profile.go index 4e0fc9fdf..2d0c265d5 100644 --- a/apps/parsar-daemon/internal/agent/codex/permission_profile.go +++ b/apps/parsar-daemon/internal/agent/codex/permission_profile.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" ) // The deployment selects a native named profile; request options cannot select it. @@ -30,18 +31,21 @@ func validatePermissionProfile(req proto.PromptRequestPayload, profile string) e // Public or prompt options cannot choose a native profile or widen that binding. func managedPermissionProfile(req proto.PromptRequestPayload, cfg sessionConfig) (string, error) { profile := cfg.permissionProfile - if cfg.runtimeNetworkAccess != "" { - if req.LocalEnvironment == nil || req.LocalEnvironment.NetworkAccess != cfg.runtimeNetworkAccess || profile != "managed-workspace" { + if cfg.runtimeNetworkError != nil { + return "", cfg.runtimeNetworkError + } + if cfg.runtimeNetwork.Access != "" { + if req.LocalEnvironment == nil || !cfg.runtimeNetwork.Equal(agentnetwork.Policy{Access: req.LocalEnvironment.NetworkAccess, AllowedDomains: req.LocalEnvironment.AllowedDomains}) || profile != "managed-workspace" || cfg.harnessBinary != "" { return "", errors.New("codex: Runtime network policy mismatch") } - switch cfg.runtimeNetworkAccess { + switch cfg.runtimeNetwork.Access { case "disabled": - case "enabled": + case "enabled", "restricted": profile = "managed-workspace-enabled" default: return "", errors.New("codex: unsupported Runtime network policy") } - } else if req.LocalEnvironment != nil && req.LocalEnvironment.NetworkAccess != "" { + } else if req.LocalEnvironment != nil { return "", errors.New("codex: Runtime has no bound network policy") } return profile, validatePermissionProfile(req, profile) diff --git a/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go b/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go index 50c1cb23c..289180d70 100644 --- a/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go +++ b/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go @@ -2,6 +2,7 @@ package codex import ( "context" + "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" "os" "path/filepath" "testing" @@ -63,7 +64,7 @@ func TestManagedNetworkPolicySelectsNativeProfileAndRejectsMismatchBeforeState(t root := filepath.Join(t.TempDir(), "uncreated") t.Setenv("PARSAR_HOME", root) req := proto.PromptRequestPayload{AgentStateKey: "session", LocalEnvironment: &proto.LocalEnvironment{ID: "environment", NetworkAccess: mode}} - cfg := sessionConfig{permissionProfile: "managed-workspace", runtimeNetworkAccess: mode} + cfg := sessionConfig{permissionProfile: "managed-workspace", runtimeNetwork: agentnetwork.Policy{Access: mode}} wrong := req wrong.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "restricted"} if _, _, err := prepareSessionPlan(t.Context(), wrong, cfg); err == nil { diff --git a/apps/parsar-daemon/internal/agent/codex/preparation.go b/apps/parsar-daemon/internal/agent/codex/preparation.go index 6470334bd..25bacc779 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation.go @@ -96,6 +96,12 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg return nil, err } + if err := configureManagedNetworkProcess(&rpcCfg, plan.managedRequirements); err != nil { + cancelFn() + plan.Cleanup() + return nil, err + } + rpc := NewJSONRPCClient(rpcCfg) defer harness.releaseWith(rpc) diff --git a/apps/parsar-daemon/internal/agent/codex/session.go b/apps/parsar-daemon/internal/agent/codex/session.go index 59b7e1bad..229bb3525 100644 --- a/apps/parsar-daemon/internal/agent/codex/session.go +++ b/apps/parsar-daemon/internal/agent/codex/session.go @@ -12,7 +12,9 @@ import ( "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" ) @@ -24,22 +26,24 @@ const terminalSendTimeout = 2 * time.Second // sessionConfig is the cross-cutting knob bag — production callers go // through Factory which uses defaults. type sessionConfig struct { - codexBinary string - harnessBinary string - permissionProfile string - runtimeNetworkAccess string - logger *slog.Logger - killTimeout time.Duration + codexBinary string + harnessBinary string + permissionProfile string + runtimeNetwork agentnetwork.Policy + runtimeNetworkError error + logger *slog.Logger + killTimeout time.Duration } func defaultSessionConfig() sessionConfig { + policy, err := localworkspace.RuntimeNetworkPolicy() return sessionConfig{ - codexBinary: defaultBinary(), - harnessBinary: os.Getenv("PARSAR_CODEX_HARNESS_BIN"), - permissionProfile: os.Getenv("PARSAR_CODEX_PERMISSION_PROFILE"), - runtimeNetworkAccess: os.Getenv("PARSAR_RUNTIME_NETWORK_ACCESS"), - logger: obslog.Bg(), - killTimeout: rpcKillTimeout, + codexBinary: defaultBinary(), + harnessBinary: os.Getenv("PARSAR_CODEX_HARNESS_BIN"), + permissionProfile: os.Getenv("PARSAR_CODEX_PERMISSION_PROFILE"), + runtimeNetwork: policy, runtimeNetworkError: err, + logger: obslog.Bg(), + killTimeout: rpcKillTimeout, } } diff --git a/apps/parsar-daemon/internal/agent/codex/session_plan.go b/apps/parsar-daemon/internal/agent/codex/session_plan.go index aa4b8071d..cf0895820 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_plan.go +++ b/apps/parsar-daemon/internal/agent/codex/session_plan.go @@ -89,5 +89,11 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg configureRemoteEnvironment(&plan, *req.RemoteEnvironment) } plan.Env = append(plan.Env, mcpBearerEnv...) + if cfg.runtimeNetwork.Access == "restricted" { + if err := prepareManagedNetwork(&plan, cfg.runtimeNetwork); err != nil { + plan.Cleanup() + return SessionPlan{}, "", err + } + } return plan, skillRoot, nil } diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace.go b/apps/parsar-daemon/internal/agent/mcode/workspace.go index b5b99d07a..3dedae717 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace.go +++ b/apps/parsar-daemon/internal/agent/mcode/workspace.go @@ -11,19 +11,21 @@ import ( "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" ) // WorkspaceConfig is frozen deployment input, separate from public Agent options. type WorkspaceConfig struct { Binary, Node, Bridge, Directory, Network, Scratch string ProtectedDirs []string + AllowedDomains []string } -func ConfigureLocal(binary, node, bridge, root, workspace, network, staging string) (WorkspaceConfig, error) { - c := WorkspaceConfig{Binary: binary, Node: node, Bridge: bridge, Directory: workspace, Network: network, +func ConfigureLocal(binary, node, bridge, root, workspace string, network agentnetwork.Policy, staging string) (WorkspaceConfig, error) { + c := WorkspaceConfig{Binary: binary, Node: node, Bridge: bridge, Directory: workspace, Network: network.Access, AllowedDomains: network.Hosts(), Scratch: filepath.Join(root, "runtime", "mcode-tools", "scratch"), ProtectedDirs: []string{filepath.Join(root, "parsar-daemon"), filepath.Join(root, "runtime", "mcode"), filepath.Dir(workspace), staging}} - if network != "enabled" && network != "disabled" { + if network.Validate() != nil { return c, fmt.Errorf("mcode: explicit workspace network policy is required") } for _, path := range []string{binary, node, bridge, root, workspace, staging} { @@ -49,7 +51,7 @@ func ConfigureLocal(binary, node, bridge, root, workspace, network, staging stri } func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.PromptRequestPayload) (launchOptions, error) { - if !req.StrictResume || req.LocalEnvironment == nil || req.WorkDir != c.Directory || req.DisableExecutionEnvironment || req.LocalEnvironment.NetworkAccess != c.Network || req.RemoteEnvironment != nil || req.WorkspaceReadOnly { + if !req.StrictResume || req.LocalEnvironment == nil || req.WorkDir != c.Directory || req.DisableExecutionEnvironment || !(agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Equal(agentnetwork.Policy{Access: req.LocalEnvironment.NetworkAccess, AllowedDomains: req.LocalEnvironment.AllowedDomains}) || req.RemoteEnvironment != nil || req.WorkspaceReadOnly { return launchOptions{}, fmt.Errorf("mcode: execution does not match the dedicated workspace") } // Reuse public option validation and private Session state provisioning. Native @@ -92,7 +94,7 @@ func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.P if err = os.WriteFile(filepath.Join(opts.DataDir, "config.yaml"), raw, 0600); err != nil { return opts, err } - profile := map[string]any{"workspace": "/workspace", "scratch": c.Scratch, "network": c.Network, "protectedDirs": slices.Clone(c.ProtectedDirs), "skills": len(req.LocalEnvironment.Skills) > 0} + profile := map[string]any{"workspace": "/workspace", "scratch": c.Scratch, "network": c.Network, "allowedDomains": (agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Hosts(), "protectedDirs": slices.Clone(c.ProtectedDirs), "skills": len(req.LocalEnvironment.Skills) > 0} if req.LocalEnvironment.ToolEnvironment { if err := localworkspace.VerifyToolEnvironment(req.LocalEnvironment.SystemPackages); err != nil { return opts, err diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace_network_test.go b/apps/parsar-daemon/internal/agent/mcode/workspace_network_test.go new file mode 100644 index 000000000..199e6e857 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/workspace_network_test.go @@ -0,0 +1,38 @@ +package mcode + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "testing" +) + +func TestRestrictedWorkspacePolicyUsesExactBoundAuthority(t *testing.T) { + config, req, _ := workspaceFixture(t) + config.Network = "restricted" + config.AllowedDomains = []string{"Example.com", "api.example.com", "example.com"} + req.LocalEnvironment.NetworkAccess = "restricted" + req.LocalEnvironment.AllowedDomains = []string{"api.example.com", "EXAMPLE.COM"} + opts, err := prepareWorkspaceOptions(t.Context(), config, req) + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(opts.DataDir, "workspace-profile.json")) + if err != nil { + t.Fatal(err) + } + var profile struct { + Network string `json:"network"` + Domains []string `json:"allowedDomains"` + } + if err := json.Unmarshal(raw, &profile); err != nil || profile.Network != "restricted" || !slices.Equal(profile.Domains, []string{"api.example.com", "example.com"}) { + t.Fatal("native policy lost bound authority", profile, err) + } + for _, domains := range [][]string{{"example.com"}, {"example.org"}, nil} { + req.LocalEnvironment.AllowedDomains = domains + if _, err := prepareWorkspaceOptions(t.Context(), config, req); err == nil { + t.Fatal("different policy entered bound Runtime", domains) + } + } +} diff --git a/apps/parsar-daemon/internal/cli/claude_sdk.go b/apps/parsar-daemon/internal/cli/claude_sdk.go index 442f4d6dc..ba5108250 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk.go @@ -75,7 +75,7 @@ func discoverClaudeSDK(rc *runContext, profile string, check func(context.Contex if err != nil { return fail(err) } - out.Config, err = claudesdk.ConfigureLocal(out.Config, root, os.Getenv("PARSAR_RUNTIME_WORKSPACE"), binding.NetworkAccess(), os.Getenv("PARSAR_RUNTIME_STAGING")) + out.Config, err = claudesdk.ConfigureLocal(out.Config, root, os.Getenv("PARSAR_RUNTIME_WORKSPACE"), binding.NetworkPolicy(), os.Getenv("PARSAR_RUNTIME_STAGING")) if err != nil { return fail(err) } diff --git a/apps/parsar-daemon/internal/cli/mcode_workspace.go b/apps/parsar-daemon/internal/cli/mcode_workspace.go index bf62d0015..fff52dcfb 100644 --- a/apps/parsar-daemon/internal/cli/mcode_workspace.go +++ b/apps/parsar-daemon/internal/cli/mcode_workspace.go @@ -60,7 +60,7 @@ func discoverMCodeWorkspace(rc *runContext, discovery *agentCLIDiscovery) { fail(err) return } - c, err := mcode.ConfigureLocal(binary, node, os.Getenv("PARSAR_MCODE_WORKSPACE_BRIDGE"), root, os.Getenv("PARSAR_RUNTIME_WORKSPACE"), binding.NetworkAccess(), os.Getenv("PARSAR_RUNTIME_STAGING")) + c, err := mcode.ConfigureLocal(binary, node, os.Getenv("PARSAR_MCODE_WORKSPACE_BRIDGE"), root, os.Getenv("PARSAR_RUNTIME_WORKSPACE"), binding.NetworkPolicy(), os.Getenv("PARSAR_RUNTIME_STAGING")) if err == nil { err = mcode.CheckWorkspace(context.Background(), c) } diff --git a/apps/parsar-daemon/internal/localworkspace/binding.go b/apps/parsar-daemon/internal/localworkspace/binding.go index f858a1bb5..a554a21e2 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding.go +++ b/apps/parsar-daemon/internal/localworkspace/binding.go @@ -7,18 +7,20 @@ import ( "strings" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" "github.com/google/uuid" ) // Binding freezes operator-owned identity and paths for one Runtime lifetime. type Binding struct { - environment string - networkAccess string - stateKey string - workspace string - helper string - exportHelper string - writer *fileWriter + environment string + networkAccess string + allowedDomains []string + stateKey string + workspace string + helper string + exportHelper string + writer *fileWriter } func New(environment, session, workspace, helper string) (*Binding, error) { @@ -46,10 +48,11 @@ func New(environment, session, workspace, helper string) (*Binding, error) { func Load() (*Binding, error) { values := []string{os.Getenv("PARSAR_RUNTIME_ENVIRONMENT_ID"), os.Getenv("PARSAR_RUNTIME_SESSION_ID"), os.Getenv("PARSAR_RUNTIME_WORKSPACE"), os.Getenv("PARSAR_RUNTIME_DIRECTORY_HELPER")} - network := os.Getenv("PARSAR_RUNTIME_NETWORK_ACCESS") - if network != "" && network != "enabled" && network != "disabled" { - return nil, errors.New("unsupported local Runtime network policy") + policy, err := RuntimeNetworkPolicy() + if err != nil { + return nil, err } + network := policy.Access writeHelper, staging := os.Getenv("PARSAR_RUNTIME_WRITE_HELPER"), os.Getenv("PARSAR_RUNTIME_STAGING") exportHelper := os.Getenv("PARSAR_RUNTIME_EXPORT_HELPER") if strings.Join(values, "") == "" && writeHelper == "" && staging == "" && network == "" && exportHelper == "" { @@ -60,6 +63,7 @@ func Load() (*Binding, error) { return nil, err } b.networkAccess = network + b.allowedDomains = policy.Hosts() if exportHelper != "" { // Reuse the startup executable/root checks; this grants no caller authority. if _, err := New(values[0], values[1], values[2], exportHelper); err != nil { @@ -89,8 +93,11 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa r.ConversationID != "" || r.WorkspaceAuthoring || len(r.Attachments) != 0 || !r.StrictResume || !r.ReleaseOnCompletion { return r, errors.New("request does not match the dedicated local Environment") } - if (!r.WorkspaceReadOnly || r.LocalEnvironment.NetworkAccess != "") && r.LocalEnvironment.NetworkAccess != b.networkAccess { - return r, errors.New("request does not match the local Runtime network policy") + if !r.WorkspaceReadOnly || r.LocalEnvironment.NetworkAccess != "" || len(r.LocalEnvironment.AllowedDomains) > 0 { + requested := agentnetwork.Policy{Access: r.LocalEnvironment.NetworkAccess, AllowedDomains: r.LocalEnvironment.AllowedDomains} + if !b.NetworkPolicy().Equal(requested) { + return r, errors.New("request does not match the local Runtime network policy") + } } if !r.WorkspaceReadOnly { if r.LocalEnvironment.SystemPackages && !r.LocalEnvironment.ToolEnvironment { @@ -105,6 +112,3 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa } return r, nil } - -// NetworkAccess is deployment-owned; read-only workspace controls need no network. -func (b *Binding) NetworkAccess() string { return b.networkAccess } diff --git a/apps/parsar-daemon/internal/localworkspace/binding_test.go b/apps/parsar-daemon/internal/localworkspace/binding_test.go index 3e6677119..1f9983215 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding_test.go +++ b/apps/parsar-daemon/internal/localworkspace/binding_test.go @@ -21,7 +21,8 @@ func testBinding(t *testing.T) (*Binding, proto.PromptRequestPayload) { if err != nil { t.Fatal(err) } - return b, proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{ID: environment}, AgentStateKey: "agents-api-" + session, StrictResume: true, ReleaseOnCompletion: true} + b.networkAccess = "disabled" + return b, proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{ID: environment, NetworkAccess: "disabled"}, AgentStateKey: "agents-api-" + session, StrictResume: true, ReleaseOnCompletion: true} } func TestBindingRejectsScopeAndPathOverrides(t *testing.T) { diff --git a/apps/parsar-daemon/internal/localworkspace/network.go b/apps/parsar-daemon/internal/localworkspace/network.go new file mode 100644 index 000000000..62d6d8f35 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/network.go @@ -0,0 +1,32 @@ +package localworkspace + +import ( + "encoding/json" + "errors" + "os" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" +) + +// RuntimeNetworkPolicy reads deployment configuration, never caller options. +func RuntimeNetworkPolicy() (agentnetwork.Policy, error) { + policy := agentnetwork.Policy{Access: os.Getenv("PARSAR_RUNTIME_NETWORK_ACCESS")} + if raw := os.Getenv("PARSAR_RUNTIME_ALLOWED_DOMAINS"); raw != "" { + if err := json.Unmarshal([]byte(raw), &policy.AllowedDomains); err != nil { + return policy, errors.New("invalid local Runtime network domains") + } + } + // Unbound runtimes may serve operations without a hosted workspace. + if policy.Access == "" && len(policy.AllowedDomains) == 0 { + return policy, nil + } + if err := policy.Validate(); err != nil { + return policy, errors.New("unsupported local Runtime network policy") + } + return policy, nil +} + +// NetworkPolicy returns a copy of the frozen execution authority. +func (b *Binding) NetworkPolicy() agentnetwork.Policy { + return agentnetwork.Policy{Access: b.networkAccess, AllowedDomains: append([]string(nil), b.allowedDomains...)} +} diff --git a/apps/parsar-daemon/internal/localworkspace/network_policy_test.go b/apps/parsar-daemon/internal/localworkspace/network_policy_test.go index d266c81d3..9b8b6b0fe 100644 --- a/apps/parsar-daemon/internal/localworkspace/network_policy_test.go +++ b/apps/parsar-daemon/internal/localworkspace/network_policy_test.go @@ -2,6 +2,7 @@ package localworkspace import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" "testing" ) @@ -12,7 +13,7 @@ func TestRuntimeNetworkPolicyMustMatchExecutionButNotReadOnly(t *testing.T) { b.networkAccess = deployed req.LocalEnvironment.NetworkAccess = requested _, err := b.Configure(req) - if (err == nil) != (deployed == requested) { + if (err == nil) != (deployed != "" && deployed == requested) { t.Fatalf("execution policy %q/%q: %v", deployed, requested, err) } req.WorkspaceReadOnly = true @@ -23,3 +24,51 @@ func TestRuntimeNetworkPolicyMustMatchExecutionButNotReadOnly(t *testing.T) { } } } + +func TestRestrictedPolicyBindingCannotBeChangedByARequest(t *testing.T) { + b, req := testBinding(t) + b.networkAccess, b.allowedDomains = "restricted", []string{"example.com", "api.example.com"} + for _, domains := range [][]string{{"api.example.com", "EXAMPLE.com", "example.com"}, {"example.com"}, {"other.example.com"}, nil} { + req.LocalEnvironment.NetworkAccess = "restricted" + req.LocalEnvironment.AllowedDomains = domains + _, err := b.Configure(req) + if (err == nil) != (len(domains) == 3) { + t.Fatalf("binding changed by domains %v: %v", domains, err) + } + } + copy := b.NetworkPolicy() + copy.AllowedDomains[0] = "other.example.com" + if !b.NetworkPolicy().Equal(agentnetwork.Policy{Access: "restricted", AllowedDomains: []string{"example.com", "api.example.com"}}) { + t.Fatal("caller mutated frozen policy") + } + req.WorkspaceReadOnly = true + req.LocalEnvironment = &proto.LocalEnvironment{ID: b.environment} + if _, err := b.Configure(req); err != nil { + t.Fatal("read requires execution network", err) + } + req.LocalEnvironment.AllowedDomains = []string{"other.example.com"} + if _, err := b.Configure(req); err == nil { + t.Fatal("read accepted a conflicting supplied policy") + } +} + +func TestRuntimeNetworkPolicyRejectsMalformedDeploymentInput(t *testing.T) { + for _, tc := range []struct { + access, domains string + valid bool + }{ + {"restricted", `["example.com"]`, true}, + {"restricted", `[]`, false}, + {"restricted", `["*"]`, false}, + {"restricted", `"example.com"`, false}, + {"enabled", `["example.com"]`, false}, + {"", `["example.com"]`, false}, + {"disabled", `[]`, true}, + } { + t.Setenv("PARSAR_RUNTIME_NETWORK_ACCESS", tc.access) + t.Setenv("PARSAR_RUNTIME_ALLOWED_DOMAINS", tc.domains) + if _, err := RuntimeNetworkPolicy(); (err == nil) != tc.valid { + t.Fatalf("%s/%s: %v", tc.access, tc.domains, err) + } + } +} diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index bf9d06f0a..57be10a8d 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -155,7 +155,7 @@ user-managed enrollment remain outside this qualification. | Area | Missing or unverified scope | | --- | --- | | Subagents / multi_agent | Six public child read operations, enabled execution, child lifecycle/interactions and full recovery; deferred outside the MVP | -| Environment Templates | Skills references, Plugins, capability directories, restricted network, installation overrides/null network and exact hosted errors; CRUD/list, files, env/setup/system/npm/Python, inline Skills and Session references are supported | +| Environment Templates | Skills references, Plugins, capability directories, unsupported restricted hostname forms, installation overrides/null network and exact hosted errors; CRUD/list, files, env/setup/system/npm/Python, inline Skills and Session references are supported | | Input and configuration | Non-text initial input, broader content/configuration unions, structured output and reasoning/verbosity combinations | | Tools and interactions | Deferred functions, other tool types, effective tool-set enforcement and result/cancel publication ordering; MiniMax public functions/MCP remain unsupported | | Vault and Credentials | OAuth/refresh, archive semantics, revocation/concurrent mutation and exact hosted selection/error behavior; static bearer CRUD/token replacement is already present | @@ -402,7 +402,7 @@ operator setup: [Codex](../../services/agents-api/deploy/codex/README.md), images as pinned templates. The shared initialization path supports env/setup and system/npm/Python packages; see the [evidence and limits](environment-templates.md#verification). Remaining -unsupported startup installations, restricted domains and hosted public HTTP MCP +unsupported startup installations, unqualified restricted hostname forms and hosted public HTTP MCP remain outside these accepted profiles. MiniMax's private MCP tool bridge is internal transport, not public MCP support. diff --git a/contracts/agents-api/environment-templates.md b/contracts/agents-api/environment-templates.md index 95c159cbe..60b42d9c7 100644 --- a/contracts/agents-api/environment-templates.md +++ b/contracts/agents-api/environment-templates.md @@ -13,7 +13,7 @@ and five-operation SandboxProvider path as inline configuration. Every operation requires project authentication and `OpenAI-Beta: agents=v1`. CRUD/list works without an execution deployment. - Optional nullable name, preserved verbatim, with a local 1–256 Unicode character - bound. Network supports `enabled` and `disabled`; omitted/null create network + bound. Network supports `enabled`, `disabled` and exact-host `restricted`; omitted/null create network defaults to the pinned enabled policy. Update omission preserves; supplied name or network replaces, with null clearing name or resetting network. - Empty/null installation fields retain empty defaults. Responses contain safe @@ -23,7 +23,8 @@ and five-operation SandboxProvider path as inline configuration. Creation timestamp plus ID supplies stable local ordering. Missing/foreign IDs and cursors return the same not-found result. No compute is allocated by CRUD. - Session `environment_template_id` resolves under the caller's tenant. Omitted - network inherits; enabled can narrow to disabled, never the reverse. Effective + network inherits; enabled can narrow to restricted or disabled. Restricted can + narrow to an exact-host subset or disabled; disabled cannot widen. Effective configuration is frozen without passing the template ID to execution. - Updating/deleting a template does not change existing Sessions. Creation retries recover recorded caller intent before template lookup, including after deletion; @@ -210,10 +211,30 @@ root installation or package retry mechanism. Existing operation and initializat time budgets apply. New harnesses implement the same Runtime contract rather than adding template-specific business logic. +## Restricted network policy + +Template and inline configuration share Core validation, persistence and resolution. +`restricted` requires 1–100 exact ASCII hostnames; subdomains and redirect destinations +need their own entries. Unsupported host forms (wildcards, URL/port syntax, IP literals, +Unicode and trailing dots) reject explicitly. This is a qualified subset, not a +claim of complete upstream hostname normalization or TLS routing semantics. +Public reads preserve supplied spelling, order and duplicates. Effective native +comparison uses a separate lowercase, deduplicated copy; updates cannot change +existing Session snapshots or retry intent. + +Provider bootstrap and preparation carry the same frozen policy. Runtime rejects +mismatches and missing hosted execution policy. Read-only workspace access retains +its existing minimal prerequisites. Core owns no native proxy configuration: +Codex uses its managed network ceiling, while Claude and MiniMax use native sandbox +allowlists. Provisioning remains a separate phase before runtime restrictions. +Current qualification evidence must cover real Docker native execution, permitted +and denied hosts, credential isolation, Files/Artifacts, cancellation and retained +policy on recovery; resource tests alone do not establish execution compatibility. + ## Explicit gaps and evidence boundaries Nonempty `capability_directories` and `plugins`, and Skills API references, -plus restricted-domain network policy, remain unsupported +remain unsupported for both templates and inline initialization. The separate live Files API remains available after initialization. Unsupported requests reject without echoing payloads. @@ -236,7 +257,7 @@ System packages use the isolated tool root described above. The [update Reference](https://developers.openai.com/api/reference/python/resources/beta/subresources/agents/subresources/environments/subresources/templates/methods/update) defines runtime network as post-setup and packages as preceding that policy. Initialization therefore uses its isolated provisioning network; native tools -apply the requested enabled/disabled policy afterward. Allowing setup internet is +apply the requested enabled/disabled/restricted policy afterward. Allowing setup internet is an implementation inference from that phase boundary, not an explicit upstream guarantee. Env values are intentionally readable by Agent code; they must not appear automatically in public metadata or initialization diagnostics. @@ -250,6 +271,47 @@ batch rather than guessing inheritance. This batch is not full protocol compatib ## Verification +### Restricted-network Docker acceptance (2026-09-21) + +The fixed SDK 3.13.0 and raw HTTP passed restricted-policy CRUD, tenant isolation, +template narrowing and immutable creation-retry checks against the standalone Core. +Current Core/daemon builds with Codex 0.153.4, Claude Code and MiniMax Code passed +real Kimi/MiniMax execution, initialized system tools and Skills, Files/Artifacts, +credential isolation, cancellation with observed descendant cleanup, and retained +workspace/native history after separate Core and Runtime restarts. Codex exercised +both template and inline configuration; Claude and MiniMax exercised templates. + +Separate real-model network runs on all three Docker profiles verified HTTP and +certificate-checked HTTPS to allowed sites, rejection of an unlisted host and +subdomain, rejection after an allowed site's redirect, and failure of direct-IP or +proxy-free access. Allowed HTTPS, host rejection and direct-bypass checks repeated +after Core/Runtime restart with the frozen policy and retained conversation history. +The checks use actual tool effects, native command Items where available and exact +transport connection counts, not the model's assessment. All accepted runs cleaned +their owned containers, volumes and test transport. + +The test host lacked direct DNS/TCP egress. A task-only network-namespace route and +transparent sidecar carried unchanged HTTP/TLS bytes to real sites through the +existing outlet; host routes, Runtime capabilities, native policy and certificate +validation stayed unchanged. This qualifies native enforcement through that test +outlet, not production direct egress or DNS. An earlier Codex probe incorrectly +required a complete result file after native denial; the corrected probe requires +the corresponding native rejection when the tool is interrupted. One initial +MiniMax run failed before its first tool call; an independent rerun passed, without +a Core change or an established root cause for that failure. + +Focused policy/API/adapter/PostgreSQL tests, the real Codex managed-process lifecycle +test and the complete Core `make check` passed. Optional Docker fault fixtures were +not enabled; real public Docker runs cover the accepted paths. The first full-check +invocation lacked the server's OpenSSL development paths; the corrected invocation +passed with a fresh database. Evidence and failed attempts remain under +`~/.parsar/remediation/20260921/template-network-native/` and the Feishu task record. +Core SHA-256: `0dc40384192fc75c4be9896072dfe0089c30a02e44804faca7a14c7d8525efa3`. +E2B probes remain mechanism evidence only; this batch does not qualify official +E2B self-hosted onboarding or complete upstream network semantics. + +### Resource and initialization checks + `official_environment_templates.py` checks all five fixed-SDK operations plus raw HTTP, exact safe response shapes, field replacement/defaults, pagination, tenant isolation and rejected confidential canaries. `official_e2b_v1.py` opts in with diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index d5210176c..e67afa53a 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -58,11 +58,14 @@ and retry identity before the existing leased Worker provisions its allocation. A committed creation interrupted before bootstrap is recovered without replaying an existing allocation's Create. -Omitted/null network defaults to enabled; explicit enabled and disabled use the -same image with adapter-selected immutable native policy. Unsupported restricted -domains and populated env/packages/setup/plugins/skills/capability -paths fail explicitly. Initial inline/file_id files use the shared hosted initializer. Empty/null installation defaults produce safe empty metadata, -not a live workspace inventory. Hosted MCP combinations remain unimplemented. +Omitted/null network defaults to enabled. Enabled, disabled and exact-host restricted +policies use the same qualified image with adapter-selected immutable native policy. +Templates and inline configuration share initial files, env, packages, ordered setup +and inline Skills through the hosted initializer. Unsupported hostname forms, +Plugins, Skill references and capability-directory imports reject explicitly; see +the [Template coverage and limits](environment-templates.md). Empty/null installation +defaults produce safe empty metadata, not a live workspace inventory. Hosted MCP +combinations remain unimplemented. Initial provisioning leaves a Session idle until a Turn starts, with no caller connection action. The managed scan records authenticated, exactly bound daemon diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index 9dd30cf08..f6cd5c02f 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -2113,11 +2113,12 @@ paths: post: consumes: - application/json - description: Saves tenant-owned basic hosted configuration. Supports nullable - name, enabled/disabled network, initial inline/file_id files, confidential + description: Saves tenant-owned hosted configuration. Supports nullable + name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, system/npm/Python packages and inline Skill ZIPs. - Omitted/null network defaults to enabled. Other populated installations and - restricted network are rejected before persistence without echoing input. + Omitted/null network defaults to enabled. Restricted network requires 1–100 + exact ASCII hostnames; other host forms and populated unsupported installations + are rejected before persistence without echoing input. No compute is allocated. Exact hosted error/retry semantics remain unverified. parameters: - description: agents=v1 @@ -2421,8 +2422,9 @@ paths: supports non-deferred function tools with text results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to - enabled; disabled is also supported, while restricted domains and remaining - unsupported startup installations are rejected. Confidential env, system/npm/Python + enabled; disabled and restricted exact ASCII hostnames are supported. Restricted + policy requires 1–100 allowed domains. Unsupported hostname forms and startup + installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the diff --git a/internal/agentdaemon/proto/environment.go b/internal/agentdaemon/proto/environment.go index 0ea690940..8bbf3028a 100644 --- a/internal/agentdaemon/proto/environment.go +++ b/internal/agentdaemon/proto/environment.go @@ -12,7 +12,8 @@ type LocalEnvironment struct { // SystemPackages requires the installed Runtime tool root during execution. SystemPackages bool `json:"system_packages,omitempty"` // NetworkAccess must match the immutable Runtime policy for execution. - NetworkAccess string `json:"network_access,omitempty"` + NetworkAccess string `json:"network_access,omitempty"` + AllowedDomains []string `json:"allowed_domains,omitempty"` } func (r PromptRequestPayload) EnvironmentID() string { diff --git a/internal/agentnetwork/policy.go b/internal/agentnetwork/policy.go new file mode 100644 index 000000000..fb94397bc --- /dev/null +++ b/internal/agentnetwork/policy.go @@ -0,0 +1,95 @@ +// Package agentnetwork validates the hosted execution policy without selecting +// a harness, Provider, native proxy or initialization mechanism. +package agentnetwork + +import ( + "errors" + "net/netip" + "slices" + "strings" +) + +var ErrInvalid = errors.New("invalid hosted network policy") + +type Policy struct { + Access string `json:"access"` + AllowedDomains []string `json:"allowed_domains"` +} + +// Validate accepts the qualified exact ASCII hostname form. It does not rewrite +// public field spelling, order or duplicates, or infer defaults from missing access. +func (p Policy) Validate() error { + switch p.Access { + case "enabled", "disabled": + if len(p.AllowedDomains) == 0 { + return nil + } + case "restricted": + if len(p.AllowedDomains) < 1 || len(p.AllowedDomains) > 100 { + return ErrInvalid + } + for _, domain := range p.AllowedDomains { + if !hostname(domain) { + return ErrInvalid + } + } + return nil + } + return ErrInvalid +} + +func hostname(value string) bool { + if len(value) == 0 || len(value) > 253 { + return false + } + if _, err := netip.ParseAddr(value); err == nil { + return false + } + for _, label := range strings.Split(value, ".") { + if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return false + } + for _, c := range label { + if !(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') && !(c >= '0' && c <= '9') && c != '-' { + return false + } + } + } + return true +} + +// Hosts is the deterministic native allowlist. The caller must validate first. +// DNS host identity is case-insensitive; public metadata remains untouched. +func (p Policy) Hosts() []string { + hosts := make([]string, len(p.AllowedDomains)) + for i, host := range p.AllowedDomains { + hosts[i] = strings.ToLower(host) + } + slices.Sort(hosts) + return slices.Compact(hosts) +} + +// Narrows reports whether this policy is a valid subset of the template policy. +func (p Policy) Narrows(template Policy) bool { + if p.Validate() != nil || template.Validate() != nil { + return false + } + if template.Access == "enabled" || p.Access == "disabled" { + return true + } + if template.Access != "restricted" || p.Access != "restricted" { + return false + } + hosts := template.Hosts() + for _, host := range p.Hosts() { + if _, ok := slices.BinarySearch(hosts, host); !ok { + return false + } + } + return true +} + +// Equal compares validated effective authority, independent of public list order. +func (p Policy) Equal(other Policy) bool { + return p.Access == other.Access && p.Validate() == nil && other.Validate() == nil && slices.Equal(p.Hosts(), other.Hosts()) +} diff --git a/internal/agentnetwork/policy_test.go b/internal/agentnetwork/policy_test.go new file mode 100644 index 000000000..581d34e2c --- /dev/null +++ b/internal/agentnetwork/policy_test.go @@ -0,0 +1,60 @@ +package agentnetwork + +import ( + "reflect" + "strings" + "testing" +) + +func TestPolicyValidationRejectsPatternsAndAddressSyntax(t *testing.T) { + for _, host := range []string{"", "*.example.com", "example.com:443", "https://example.com", "example.com/path", "example.com?x", "example.com#x", "user@example.com", " example.com", "example.com\n", "example..com", "-example.com", "example-.com", "example_com", "127.0.0.1", "::1", "example.com.", "例子.com", strings.Repeat("x", 64) + ".com"} { + if (Policy{Access: "restricted", AllowedDomains: []string{host}}).Validate() == nil { + t.Fatalf("accepted an unqualified hostname %q", host) + } + } + for _, policy := range []Policy{{}, {Access: "unknown"}, {Access: "restricted"}, {Access: "enabled", AllowedDomains: []string{"example.com"}}, {Access: "disabled", AllowedDomains: []string{"example.com"}}, {Access: "restricted", AllowedDomains: make([]string, 101)}} { + if policy.Validate() == nil { + t.Fatalf("accepted invalid policy %#v", policy) + } + } + for _, policy := range []Policy{{Access: "enabled"}, {Access: "disabled"}, {Access: "restricted", AllowedDomains: []string{"Example.com", "api.example.com", "xn--fsqu00a.com"}}} { + if err := policy.Validate(); err != nil { + t.Fatalf("rejected policy %#v: %v", policy, err) + } + } +} + +func TestTemplateNetworkOverrideCannotBroaden(t *testing.T) { + policies := []Policy{ + {Access: "enabled"}, + {Access: "disabled"}, + {Access: "restricted", AllowedDomains: []string{"a.example.com", "b.example.com"}}, + {Access: "restricted", AllowedDomains: []string{"A.example.com"}}, + {Access: "restricted", AllowedDomains: []string{"sub.a.example.com"}}, + } + // Rows are templates; columns are requested effective policies. + want := [][]bool{{true, true, true, true, true}, {false, true, false, false, false}, {false, true, true, true, false}, {false, true, false, true, false}, {false, true, false, false, true}} + for i, template := range policies { + for j, requested := range policies { + if requested.Narrows(template) != want[i][j] { + t.Errorf("template %d override %d", i, j) + } + } + } + if (Policy{Access: "restricted"}).Narrows(policies[0]) || policies[1].Narrows(Policy{}) { + t.Fatal("invalid policy cannot gain authority through an override") + } +} + +func TestPolicyIdentityPreservesPublicInput(t *testing.T) { + input := []string{"B.example.com", "a.example.com", "B.example.com"} + before := append([]string(nil), input...) + policy := Policy{Access: "restricted", AllowedDomains: input} + other := Policy{Access: "restricted", AllowedDomains: []string{"a.example.com", "b.example.com"}} + if !policy.Equal(other) || !reflect.DeepEqual(input, before) { + t.Fatal("effective comparison changed caller input or list order affected authority") + } + if policy.Equal(Policy{Access: "restricted", AllowedDomains: []string{"example.com"}}) { + t.Fatal("parent hostname must not grant subdomain authority") + } +} diff --git a/packages/claude-sdk-adapter/src/workspace.ts b/packages/claude-sdk-adapter/src/workspace.ts index 3bb802dd0..46647d589 100644 --- a/packages/claude-sdk-adapter/src/workspace.ts +++ b/packages/claude-sdk-adapter/src/workspace.ts @@ -1,6 +1,7 @@ import { parseSkills, workspaceSkills, type WorkspaceSkill } from "./workspace_skills.js"; import type { CanUseTool, HookCallback, Options } from "@anthropic-ai/claude-agent-sdk"; import { lstatSync, realpathSync, statSync } from "node:fs"; +import { isIP } from "node:net"; import { dirname, isAbsolute, join, resolve } from "node:path"; export type Workspace = { @@ -13,7 +14,8 @@ export type Workspace = { skills?: WorkspaceSkill[]; tool_environment?: boolean; system_packages?: boolean; - network_access?: "enabled" | "disabled"; + network_access?: "enabled" | "disabled" | "restricted"; + allowed_domains?: string[]; }; const environmentNames = new Set([ @@ -43,15 +45,20 @@ export function parseWorkspace(value: unknown, cwd: string): Workspace | undefin if (value === undefined) return undefined; if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); const config = value as Record; - if (Object.keys(config).some(key => !["home", "state", "scratch", "protected_dirs", "dependency_path", "env_names", "network_access", "tool_environment", "system_packages", "skills"].includes(key)) || + if (Object.keys(config).some(key => !["home", "state", "scratch", "protected_dirs", "dependency_path", "env_names", "network_access", "allowed_domains", "tool_environment", "system_packages", "skills"].includes(key)) || (config.tool_environment !== undefined && typeof config.tool_environment !== "boolean") || (config.system_packages !== undefined && typeof config.system_packages !== "boolean") || (config.system_packages === true && config.tool_environment !== true) || - (config.network_access !== undefined && config.network_access !== "enabled" && config.network_access !== "disabled") || + (config.network_access !== undefined && config.network_access !== "enabled" && config.network_access !== "disabled" && config.network_access !== "restricted") || !Array.isArray(config.protected_dirs) || !Array.isArray(config.env_names) || typeof config.dependency_path !== "string" || !config.dependency_path || config.env_names.some(name => typeof name !== "string" || !environmentNames.has(name)) || new Set(config.env_names).size !== config.env_names.length) throw new Error("invalid_request"); + const domains = config.allowed_domains ?? []; + const hostname = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i; + if (!Array.isArray(domains) || (config.network_access === "restricted" + ? domains.length < 1 || domains.length > 100 || domains.some(host => typeof host !== "string" || !hostname.test(host) || host.trim() !== host || isIP(host) !== 0) + : domains.length !== 0)) throw new Error("invalid_request"); const roots = [cwd, config.home, config.state, config.scratch, ...config.protected_dirs].map(path => directory(path, true)); if (roots.some((root, index) => roots.some((other, otherIndex) => index !== otherIndex && contains(root, other)))) { throw new Error("invalid_request"); @@ -115,7 +122,7 @@ export class WorkspaceProfile { envVars: [...new Set([...credentialNames, ...config.env_names])].map(name => ({ name, mode: "deny" })), files: protectedRoots.map(path => ({ path, mode: "deny" })), }, - network: { allowedDomains: config.network_access === "enabled" ? ["*"] : [], strictAllowlist: true, allowAllUnixSockets: false, allowLocalBinding: false }, + network: { allowedDomains: config.network_access === "enabled" ? ["*"] : config.network_access === "restricted" ? [...config.allowed_domains!] : [], strictAllowlist: true, allowAllUnixSockets: false, allowLocalBinding: false }, }, canUseTool: this.canUseTool, hooks: { PreToolUse: [{ hooks: [this.beforeTool] }] }, diff --git a/packages/claude-sdk-adapter/tests/workspace.test.mjs b/packages/claude-sdk-adapter/tests/workspace.test.mjs index d070c4898..27fa644f6 100644 --- a/packages/claude-sdk-adapter/tests/workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace.test.mjs @@ -172,6 +172,15 @@ test("dedicated Runtime carries an explicit native network policy", t => { assert.equal(options.sandbox.allowUnsandboxedCommands, false); } assert.throws(() => parseStart(JSON.stringify({ ...request, workspace: { ...config, network_access: "restricted" } })), /invalid_request/); + const restricted = { ...config, network_access: "restricted", allowed_domains: ["example.com", "api.example.com"] }; + const parsed = parseStart(JSON.stringify({ ...request, workspace: restricted })); + assert.deepEqual(parsed.workspace.allowed_domains, restricted.allowed_domains); + const network = new WorkspaceProfile(dirs.workspace, restricted).options.sandbox.network; + assert.deepEqual(network, { allowedDomains: restricted.allowed_domains, strictAllowlist: true, allowAllUnixSockets: false, allowLocalBinding: false }); + for (const allowed_domains of [["*"], ["*.example.com"], ["example.com:443"], ["127.0.0.1"], ["example.com\n"], []]) { + assert.throws(() => parseStart(JSON.stringify({ ...request, workspace: { ...restricted, allowed_domains } })), /invalid_request/); + } + assert.throws(() => parseStart(JSON.stringify({ ...request, workspace: { ...restricted, network_access: "enabled" } })), /invalid_request/); const { workspace, ...none } = request; assert.throws(() => parseStart(JSON.stringify({ ...none, require_history: true })), /invalid_request/); }); diff --git a/packages/mcode-harness/launch.mjs b/packages/mcode-harness/launch.mjs index 51d7ddb29..ce692d3e6 100644 --- a/packages/mcode-harness/launch.mjs +++ b/packages/mcode-harness/launch.mjs @@ -1,13 +1,19 @@ import { SandboxManager } from './dist/sandbox.mjs'; import { spawn } from 'node:child_process'; import { readFileSync, mkdirSync } from 'node:fs'; +import { isIP } from 'node:net'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const profile = JSON.parse(readFileSync(process.argv[2], 'utf8')); if (profile.workspace !== process.argv[3] || profile.workspace !== '/workspace') throw new Error('Workspace profile does not match execution binding'); -if (!['disabled','enabled'].includes(profile.network)) throw new Error('Invalid network policy'); +if (!['disabled','enabled','restricted'].includes(profile.network)) throw new Error('Invalid network policy'); +const domains=profile.allowedDomains??[]; +const hostname=/^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i; +if (!Array.isArray(domains) || (profile.network==='restricted' + ? domains.length<1 || domains.length>100 || domains.some(host=>typeof host!=='string'||!hostname.test(host)||host.trim()!==host||isIP(host)!==0) + : domains.length!==0)) throw new Error('Invalid network domains'); const baseEnv = {PATH:'/usr/local/bin:/usr/bin:/bin',HOME:profile.scratch,TMPDIR:profile.scratch,LANG:'C.UTF-8'}; if (profile.systemPackages) { if (!profile.toolEnvironment) throw new Error('System packages require initialized tool configuration'); @@ -20,7 +26,7 @@ process.on('SIGTERM',cancel);process.on('SIGINT',cancel); try { mkdirSync(profile.scratch,{recursive:true}); await SandboxManager.initialize({ - network:{allowedDomains:[],deniedDomains:profile.network==='disabled'?['*']:[],allowAll:profile.network==='enabled'}, + network:{allowedDomains:domains,deniedDomains:profile.network==='disabled'?['*']:[],allowAll:profile.network==='enabled',strictAllowlist:true,allowAllUnixSockets:false,allowLocalBinding:false}, filesystem:{denyRead:profile.protectedDirs,allowWrite:[profile.workspace,profile.scratch,...(profile.toolEnvironment ? ['/environment/packages'] : [])],denyWrite:[...(profile.skills ? ["/environment/initialization/capabilities"] : []),...(profile.systemPackages ? ['/environment/packages/system'] : [])]}, seccomp:{applyPath:join(here,'dist/vendor/seccomp/x64/apply-seccomp')}, },undefined,false); diff --git a/scripts/build-agents-api.sh b/scripts/build-agents-api.sh index f24443d0e..ff3471e85 100755 --- a/scripts/build-agents-api.sh +++ b/scripts/build-agents-api.sh @@ -21,7 +21,7 @@ tar -C "$repo_root" -cf - \ go.mod go.sum \ contracts/agents-api/v1 \ internal/agentdaemon/device internal/agentdaemon/gateway internal/agentdaemon/proto \ - internal/agentskill internal/obs/log services/agents-api \ + internal/agentnetwork internal/agentskill internal/obs/log services/agents-api \ | tar -C "$build_context" -xf - ( diff --git a/services/agents-api/deploy/codex/README.md b/services/agents-api/deploy/codex/README.md index 9007e32f0..8abad602a 100644 --- a/services/agents-api/deploy/codex/README.md +++ b/services/agents-api/deploy/codex/README.md @@ -181,8 +181,11 @@ initial-text Session using `environment: {"type": "openai_hosted"}`. Core commit its identity before automatically provisioning it. Queries expose durable connection status; execution separately prepares the native harness. Session deletion revokes authority before owned container/volume cleanup. Supported network -policies are enabled and disabled. Templates, populated startup installations, -restricted domains and hosted MCP combinations remain explicit gaps. +policies are enabled, disabled and restricted to exact ASCII hostnames. Templates +and inline configuration share initial files, env, packages, ordered setup and inline +Skills; see the [supported fields and limits](../../../../contracts/agents-api/environment-templates.md). +Other hostname forms, Plugins, Skill references, capability-directory imports and +hosted MCP combinations remain explicit gaps. ### Environment initialization diff --git a/services/agents-api/internal/api/environment_network_test.go b/services/agents-api/internal/api/environment_network_test.go new file mode 100644 index 000000000..8bc63b31b --- /dev/null +++ b/services/agents-api/internal/api/environment_network_test.go @@ -0,0 +1,94 @@ +package api + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestRestrictedNetworkPublicMetadataPreservesInput(t *testing.T) { + domains := []string{"Example.com", "api.example.com", "example.com"} + network := `{"access":"restricted","allowed_domains":["Example.com","api.example.com","example.com"]}` + in, err := decodeTemplateInput([]byte(`{"network":` + network + `}`)) + if err != nil || !in.SetNetwork || !reflect.DeepEqual(in.AllowedDomains, domains) { + t.Fatal("template input changed", in, err) + } + response := templateResponse(store.EnvironmentTemplate{NetworkAccess: in.NetworkAccess, AllowedDomains: in.AllowedDomains}) + if response.Network.Access != "restricted" || !reflect.DeepEqual(response.Network.AllowedDomains, domains) { + t.Fatal("template response changed", response.Network) + } + raw := json.RawMessage(`{"type":"openai_hosted","network":` + network + `}`) + env, err := decodeHostedEnvironment(raw) + if err != nil || !reflect.DeepEqual(env.Network.AllowedDomains, domains) { + t.Fatal("inline input changed", env, err) + } + session, err := hostedSessionEnvironment(store.Environment{ID: "environment", Configuration: raw}) + if err != nil || !reflect.DeepEqual(session.Network.AllowedDomains, domains) { + t.Fatal("frozen Session metadata changed", session, err) + } + for _, invalid := range []string{ + `{"access":"restricted"}`, `{"access":"restricted","allowed_domains":null}`, + `{"access":"restricted","allowed_domains":[]}`, + `{"access":"restricted","allowed_domains":["*.example.com"]}`, + `{"access":"restricted","allowed_domains":["https://example.com"]}`, + `{"access":"restricted","allowed_domains":[null]}`, + `{"access":"enabled","allowed_domains":["example.com"]}`, + } { + if _, err := decodeTemplateInput([]byte(`{"network":` + invalid + `}`)); err == nil { + t.Fatal("invalid template network accepted", invalid) + } + if _, err := decodeHostedEnvironment(json.RawMessage(`{"type":"openai_hosted","network":` + invalid + `}`)); err == nil { + t.Fatal("invalid inline network accepted", invalid) + } + } +} + +func TestTemplateNetworkOverridesOnlyNarrowAndRetainIntent(t *testing.T) { + lookup := &templateLookupStore{network: "restricted", domains: []string{"Example.com", "api.example.com", "example.com"}} + h := Handler{store: lookup} + for _, test := range []struct { + name, override string + want []string + invalid bool + }{ + {name: "inherit", want: lookup.domains}, + {name: "subset", override: `,"network":{"access":"restricted","allowed_domains":["API.EXAMPLE.COM"]}`, want: []string{"API.EXAMPLE.COM"}}, + {name: "same authority", override: `,"network":{"access":"restricted","allowed_domains":["example.com","api.example.com"]}`, want: []string{"example.com", "api.example.com"}}, + {name: "disabled", override: `,"network":{"access":"disabled"}`}, + {name: "enabled broadens", override: `,"network":{"access":"enabled"}`, invalid: true}, + {name: "subdomain broadens", override: `,"network":{"access":"restricted","allowed_domains":["other.example.com"]}`, invalid: true}, + {name: "foreign domain broadens", override: `,"network":{"access":"restricted","allowed_domains":["example.org"]}`, invalid: true}, + } { + t.Run(test.name, func(t *testing.T) { + var decoded decodedSessionRequest + raw := `{"agent":{"model":"test"},"environment":{"type":"openai_hosted","environment_template_id":"saved"` + test.override + `}}` + if err := json.Unmarshal([]byte(raw), &decoded); err != nil { + t.Fatal(err) + } + input, err := decoded.validated() + if err != nil { + t.Fatal(err) + } + before, err := sessionCreationRequest(input, nil) + if err != nil { + t.Fatal(err) + } + err = h.resolveTemplateEnvironment(t.Context(), "tenant", &input) + if test.invalid { + if err == nil { + t.Fatal("template authority widened") + } + return + } + if err != nil || !reflect.DeepEqual(input.Environment.Network.AllowedDomains, test.want) { + t.Fatal("effective policy changed", input.Environment.Network, err) + } + after, err := sessionCreationRequest(input, nil) + if err != nil || string(after) != string(before) { + t.Fatal("resolution rewrote creation identity", string(before), string(after), err) + } + }) + } +} diff --git a/services/agents-api/internal/api/environment_templates.go b/services/agents-api/internal/api/environment_templates.go index 40e83da0e..84ab08248 100644 --- a/services/agents-api/internal/api/environment_templates.go +++ b/services/agents-api/internal/api/environment_templates.go @@ -59,11 +59,12 @@ func decodeTemplateInput(raw []byte) (store.EnvironmentTemplateInput, error) { return in, err } in.NetworkAccess = environment.Network.Access + in.AllowedDomains = append([]string{}, environment.Network.AllowedDomains...) return in, nil } func templateResponse(t store.EnvironmentTemplate) v1.EnvironmentTemplate { - return v1.EnvironmentTemplate{ID: t.ID, Object: "agent.environment.template", Name: t.Name, CreatedAt: t.CreatedAt.Unix(), UpdatedAt: t.UpdatedAt.Unix(), CapabilityDirectories: []string{}, Network: v1.EnvironmentNetwork{Access: t.NetworkAccess, AllowedDomains: []string{}}, Packages: packageMetadata(&t.Packages), Files: templateFileResponse(t.Files), Plugins: []json.RawMessage{}, Skills: skillResponse(t.Skills)} + return v1.EnvironmentTemplate{ID: t.ID, Object: "agent.environment.template", Name: t.Name, CreatedAt: t.CreatedAt.Unix(), UpdatedAt: t.UpdatedAt.Unix(), CapabilityDirectories: []string{}, Network: v1.EnvironmentNetwork{Access: t.NetworkAccess, AllowedDomains: append([]string{}, t.AllowedDomains...)}, Packages: packageMetadata(&t.Packages), Files: templateFileResponse(t.Files), Plugins: []json.RawMessage{}, Skills: skillResponse(t.Skills)} } func templateNoQuery(w http.ResponseWriter, r *http.Request) bool { @@ -84,14 +85,14 @@ func readTemplateInput(w http.ResponseWriter, r *http.Request) (store.Environmen } in, err := decodeTemplateInput(raw) if err != nil { - writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "Template fields are invalid or require unsupported initialization. Name, enabled/disabled network, initial files, env, system/npm/Python packages, setup commands and inline Skill ZIPs are supported.") + writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "Template fields are invalid or require unsupported initialization. Name, enabled/disabled or exact-domain restricted network, initial files, env, system/npm/Python packages, setup commands and inline Skill ZIPs are supported.") return in, false } return in, true } // @Summary Create an Environment Template -// @Description Saves tenant-owned basic hosted configuration. Supports nullable name, enabled/disabled network, initial inline/file_id files, confidential env, ordered setup_commands, system/npm/Python packages and inline Skill ZIPs. Omitted/null network defaults to enabled. Other populated installations and restricted network are rejected before persistence without echoing input. No compute is allocated. Exact hosted error/retry semantics remain unverified. +// @Description Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, system/npm/Python packages and inline Skill ZIPs. Omitted/null network defaults to enabled. Restricted network requires 1–100 exact ASCII hostnames; other host forms and populated unsupported installations are rejected before persistence without echoing input. No compute is allocated. Exact hosted error/retry semantics remain unverified. // @Tags Environment Templates // @Accept json // @Produce json diff --git a/services/agents-api/internal/api/environment_templates_test.go b/services/agents-api/internal/api/environment_templates_test.go index a370ab46e..646e20bd2 100644 --- a/services/agents-api/internal/api/environment_templates_test.go +++ b/services/agents-api/internal/api/environment_templates_test.go @@ -12,12 +12,12 @@ import ( ) func TestTemplateConfigurationRejectsUnqualifiedInputs(t *testing.T) { - for _, raw := range []string{`{}`, `{"packages":{}}`, `{"packages":{"npm":null}}`, `{"packages":{"system":["jq","libpq-dev"]}}`, `{"packages":{"system":null}}`, `{"name":null,"network":null}`, `{"name":"保存","network":{"access":"disabled"},"env":{},"files":[],"setup_commands":[],"packages":{"npm":null}}`} { + for _, raw := range []string{`{"network":{"access":"restricted","allowed_domains":["Example.com","example.com"]}}`, `{}`, `{"packages":{}}`, `{"packages":{"npm":null}}`, `{"packages":{"system":["jq","libpq-dev"]}}`, `{"packages":{"system":null}}`, `{"name":null,"network":null}`, `{"name":"保存","network":{"access":"disabled"},"env":{},"files":[],"setup_commands":[],"packages":{"npm":null}}`} { if _, err := decodeTemplateInput([]byte(raw)); err != nil { t.Fatalf("supported input: %s: %v", raw, err) } } - for _, raw := range []string{`null`, `[]`, `{"name":""}`, `{"name":42}`, `{"type":"openai_hosted"}`, `{"network":{"access":"restricted","allowed_domains":["example.com"]}}`, `{"env":{"PATH":"confidential-canary"}}`, `{"setup_commands":[{"command":"confidential-canary","cwd":"relative"}]}`, `{"packages":{"system":["-o"]}}`, `{"packages":{"system":[""]}}`, `{"packages":{"system":[null]}}`, `{"plugins":[{}]}`, `{"skills":[{}]}`, `{"capability_directories":["/workspace"]}`} { + for _, raw := range []string{`null`, `[]`, `{"name":""}`, `{"name":42}`, `{"type":"openai_hosted"}`, `{"env":{"PATH":"confidential-canary"}}`, `{"setup_commands":[{"command":"confidential-canary","cwd":"relative"}]}`, `{"packages":{"system":["-o"]}}`, `{"packages":{"system":[""]}}`, `{"packages":{"system":[null]}}`, `{"plugins":[{}]}`, `{"skills":[{}]}`, `{"capability_directories":["/workspace"]}`} { if _, err := decodeTemplateInput([]byte(raw)); err == nil { t.Fatalf("unsupported input accepted: %s", raw) } @@ -36,12 +36,13 @@ func TestTemplateConfigurationRejectsUnqualifiedInputs(t *testing.T) { type templateLookupStore struct { ResourceStore network string + domains []string tenant string } func (s *templateLookupStore) ResolveEnvironmentTemplate(_ context.Context, tenant, id string) (store.EnvironmentTemplate, []store.InitialFile, error) { s.tenant = tenant - return store.EnvironmentTemplate{ID: id, NetworkAccess: s.network}, nil, nil + return store.EnvironmentTemplate{ID: id, NetworkAccess: s.network, AllowedDomains: s.domains}, nil, nil } func TestTemplateResolutionAndCreationIntent(t *testing.T) { diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index dcfd2c7c9..58b2025d9 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -120,7 +120,7 @@ func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ... // createSession atomically reserves or admits initial text with the Session. // @Summary Create an execution Session -// @Description Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, disabled multi_agent, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null, but its values must be strings. Initial input accepts a string or user-message array containing text. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Omitted or null input creates an idle Session. With stream=true, returns live Session events starting at creation; disconnect does not cancel execution. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; unrelated inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. Non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled is also supported, while restricted domains and remaining unsupported startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. Referenced files/env/packages/setup overrides are rejected pending semantic verification. Tenant-owned environment_template_id references inherit omitted network and allow only narrowing overrides. Referenced network:null is explicitly unsupported pending semantic verification. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. +// @Description Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, disabled multi_agent, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null, but its values must be strings. Initial input accepts a string or user-message array containing text. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Omitted or null input creates an idle Session. With stream=true, returns live Session events starting at creation; disconnect does not cancel execution. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; unrelated inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. Non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. Referenced files/env/packages/setup overrides are rejected pending semantic verification. Tenant-owned environment_template_id references inherit omitted network and allow only narrowing overrides. Referenced network:null is explicitly unsupported pending semantic verification. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. // @Tags Sessions // @Accept json // @Produce json,text/event-stream diff --git a/services/agents-api/internal/api/hosted_environment.go b/services/agents-api/internal/api/hosted_environment.go index 960d5d36a..3cb3d4de3 100644 --- a/services/agents-api/internal/api/hosted_environment.go +++ b/services/agents-api/internal/api/hosted_environment.go @@ -3,7 +3,9 @@ package api import ( "bytes" "encoding/json" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -27,7 +29,7 @@ func decodeHostedEnvironment(raw json.RawMessage) (*v1.Environment, error) { continue } var network v1.EnvironmentNetworkInput - if decodeInputObject(value, &network, "access", "allowed_domains") != nil || (network.Access != "enabled" && network.Access != "disabled") || len(network.AllowedDomains) != 0 { + if decodeInputObject(value, &network, "access", "allowed_domains") != nil || (agentnetwork.Policy{Access: network.Access, AllowedDomains: network.AllowedDomains}).Validate() != nil { return nil, store.ErrInvalidInput } env.Network = &network @@ -69,7 +71,7 @@ func hostedSessionEnvironment(environment store.Environment) (v1.SessionEnvironm } directories := []string{} return v1.SessionEnvironment{ID: environment.ID, Type: cfg.Type, CapabilityDirectories: &directories, - Network: &v1.EnvironmentNetwork{Access: cfg.Network.Access, AllowedDomains: []string{}}, + Network: &v1.EnvironmentNetwork{Access: cfg.Network.Access, AllowedDomains: append([]string{}, cfg.Network.AllowedDomains...)}, Packages: func() *v1.EnvironmentPackages { value := packageMetadata(cfg.Packages); return &value }(), Files: &files, Plugins: &empty, Skills: &cfg.Skills}, nil } diff --git a/services/agents-api/internal/api/session_template.go b/services/agents-api/internal/api/session_template.go index 3c0668a01..d86f361b5 100644 --- a/services/agents-api/internal/api/session_template.go +++ b/services/agents-api/internal/api/session_template.go @@ -6,6 +6,7 @@ import ( "encoding/json" v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -56,8 +57,10 @@ func (h *Handler) resolveTemplateEnvironment(ctx context.Context, tenant string, return store.ErrInvalidInput } if _, supplied := fields["network"]; !supplied { - input.Environment.Network = &v1.EnvironmentNetworkInput{Access: template.NetworkAccess} - } else if template.NetworkAccess == "disabled" && input.Environment.Network.Access != "disabled" { + input.Environment.Network = &v1.EnvironmentNetworkInput{Access: template.NetworkAccess, AllowedDomains: append([]string{}, template.AllowedDomains...)} + } + effective := agentnetwork.Policy{Access: input.Environment.Network.Access, AllowedDomains: input.Environment.Network.AllowedDomains} + if !effective.Narrows(agentnetwork.Policy{Access: template.NetworkAccess, AllowedDomains: template.AllowedDomains}) { return store.ErrInvalidInput } input.initialization = template.Initialization diff --git a/services/agents-api/internal/db/queries/environment_templates.sql b/services/agents-api/internal/db/queries/environment_templates.sql index 1a34aa4e4..1eb007e33 100644 --- a/services/agents-api/internal/db/queries/environment_templates.sql +++ b/services/agents-api/internal/db/queries/environment_templates.sql @@ -1,14 +1,15 @@ -- name: CreateEnvironmentTemplate :one -INSERT INTO environment_templates (id, tenant_id, name, network_access, files, file_contents, packages, env_contents, setup_contents, skills, skill_contents) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills; +INSERT INTO environment_templates (id, tenant_id, name, network_access, files, file_contents, packages, env_contents, setup_contents, skills, skill_contents, network_allowed_domains) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills; -- name: GetEnvironmentTemplate :one -SELECT id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills FROM environment_templates WHERE tenant_id = $1 AND id = $2; +SELECT id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills FROM environment_templates WHERE tenant_id = $1 AND id = $2; -- name: UpdateEnvironmentTemplate :one UPDATE environment_templates SET name = CASE WHEN sqlc.arg(set_name)::boolean THEN sqlc.narg(name)::text ELSE name END, network_access = CASE WHEN sqlc.arg(set_network)::boolean THEN sqlc.arg(network_access)::text ELSE network_access END, + network_allowed_domains = CASE WHEN sqlc.arg(set_network)::boolean THEN sqlc.arg(network_allowed_domains)::text[] ELSE network_allowed_domains END, files = CASE WHEN sqlc.arg(set_files)::boolean THEN sqlc.arg(files)::jsonb ELSE files END, file_contents = CASE WHEN sqlc.arg(set_files)::boolean THEN sqlc.narg(file_contents)::bytea ELSE file_contents END, packages = CASE WHEN sqlc.arg(set_packages)::boolean THEN sqlc.arg(packages)::jsonb ELSE packages END, @@ -18,13 +19,13 @@ UPDATE environment_templates SET skill_contents = CASE WHEN sqlc.arg(set_skills)::boolean THEN sqlc.narg(skill_contents)::bytea ELSE skill_contents END, updated_at = clock_timestamp() WHERE tenant_id = sqlc.arg(tenant_id) AND id = sqlc.arg(id) -RETURNING id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills; +RETURNING id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills; -- name: DeleteEnvironmentTemplate :one DELETE FROM environment_templates WHERE tenant_id = $1 AND id = $2 RETURNING id; -- name: ListEnvironmentTemplates :many -SELECT id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills FROM environment_templates +SELECT id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills FROM environment_templates WHERE tenant_id = sqlc.arg(tenant_id) AND (sqlc.narg(after_created)::timestamptz IS NULL OR (NOT sqlc.arg(ascending)::boolean AND (created_at, id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) diff --git a/services/agents-api/internal/db/sqlc/environment_templates.sql.go b/services/agents-api/internal/db/sqlc/environment_templates.sql.go index 49755205b..b468190ce 100644 --- a/services/agents-api/internal/db/sqlc/environment_templates.sql.go +++ b/services/agents-api/internal/db/sqlc/environment_templates.sql.go @@ -12,34 +12,36 @@ import ( ) const createEnvironmentTemplate = `-- name: CreateEnvironmentTemplate :one -INSERT INTO environment_templates (id, tenant_id, name, network_access, files, file_contents, packages, env_contents, setup_contents, skills, skill_contents) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills +INSERT INTO environment_templates (id, tenant_id, name, network_access, files, file_contents, packages, env_contents, setup_contents, skills, skill_contents, network_allowed_domains) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills ` type CreateEnvironmentTemplateParams struct { - ID pgtype.UUID `json:"id"` - TenantID pgtype.UUID `json:"tenant_id"` - Name pgtype.Text `json:"name"` - NetworkAccess string `json:"network_access"` - Files []byte `json:"files"` - FileContents []byte `json:"file_contents"` - Packages []byte `json:"packages"` - EnvContents []byte `json:"env_contents"` - SetupContents []byte `json:"setup_contents"` - Skills []byte `json:"skills"` - SkillContents []byte `json:"skill_contents"` + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Name pgtype.Text `json:"name"` + NetworkAccess string `json:"network_access"` + Files []byte `json:"files"` + FileContents []byte `json:"file_contents"` + Packages []byte `json:"packages"` + EnvContents []byte `json:"env_contents"` + SetupContents []byte `json:"setup_contents"` + Skills []byte `json:"skills"` + SkillContents []byte `json:"skill_contents"` + NetworkAllowedDomains []string `json:"network_allowed_domains"` } type CreateEnvironmentTemplateRow struct { - ID pgtype.UUID `json:"id"` - TenantID pgtype.UUID `json:"tenant_id"` - Name pgtype.Text `json:"name"` - NetworkAccess string `json:"network_access"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - Files []byte `json:"files"` - Packages []byte `json:"packages"` - Skills []byte `json:"skills"` + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Name pgtype.Text `json:"name"` + NetworkAccess string `json:"network_access"` + NetworkAllowedDomains []string `json:"network_allowed_domains"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Files []byte `json:"files"` + Packages []byte `json:"packages"` + Skills []byte `json:"skills"` } func (q *Queries) CreateEnvironmentTemplate(ctx context.Context, arg CreateEnvironmentTemplateParams) (CreateEnvironmentTemplateRow, error) { @@ -55,6 +57,7 @@ func (q *Queries) CreateEnvironmentTemplate(ctx context.Context, arg CreateEnvir arg.SetupContents, arg.Skills, arg.SkillContents, + arg.NetworkAllowedDomains, ) var i CreateEnvironmentTemplateRow err := row.Scan( @@ -62,6 +65,7 @@ func (q *Queries) CreateEnvironmentTemplate(ctx context.Context, arg CreateEnvir &i.TenantID, &i.Name, &i.NetworkAccess, + &i.NetworkAllowedDomains, &i.CreatedAt, &i.UpdatedAt, &i.Files, @@ -88,7 +92,7 @@ func (q *Queries) DeleteEnvironmentTemplate(ctx context.Context, arg DeleteEnvir } const getEnvironmentTemplate = `-- name: GetEnvironmentTemplate :one -SELECT id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills FROM environment_templates WHERE tenant_id = $1 AND id = $2 +SELECT id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills FROM environment_templates WHERE tenant_id = $1 AND id = $2 ` type GetEnvironmentTemplateParams struct { @@ -97,15 +101,16 @@ type GetEnvironmentTemplateParams struct { } type GetEnvironmentTemplateRow struct { - ID pgtype.UUID `json:"id"` - TenantID pgtype.UUID `json:"tenant_id"` - Name pgtype.Text `json:"name"` - NetworkAccess string `json:"network_access"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - Files []byte `json:"files"` - Packages []byte `json:"packages"` - Skills []byte `json:"skills"` + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Name pgtype.Text `json:"name"` + NetworkAccess string `json:"network_access"` + NetworkAllowedDomains []string `json:"network_allowed_domains"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Files []byte `json:"files"` + Packages []byte `json:"packages"` + Skills []byte `json:"skills"` } func (q *Queries) GetEnvironmentTemplate(ctx context.Context, arg GetEnvironmentTemplateParams) (GetEnvironmentTemplateRow, error) { @@ -116,6 +121,7 @@ func (q *Queries) GetEnvironmentTemplate(ctx context.Context, arg GetEnvironment &i.TenantID, &i.Name, &i.NetworkAccess, + &i.NetworkAllowedDomains, &i.CreatedAt, &i.UpdatedAt, &i.Files, @@ -126,7 +132,7 @@ func (q *Queries) GetEnvironmentTemplate(ctx context.Context, arg GetEnvironment } const listEnvironmentTemplates = `-- name: ListEnvironmentTemplates :many -SELECT id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills FROM environment_templates +SELECT id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills FROM environment_templates WHERE tenant_id = $1 AND ($2::timestamptz IS NULL OR (NOT $3::boolean AND (created_at, id) < ($2::timestamptz, $4::uuid)) @@ -148,15 +154,16 @@ type ListEnvironmentTemplatesParams struct { } type ListEnvironmentTemplatesRow struct { - ID pgtype.UUID `json:"id"` - TenantID pgtype.UUID `json:"tenant_id"` - Name pgtype.Text `json:"name"` - NetworkAccess string `json:"network_access"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - Files []byte `json:"files"` - Packages []byte `json:"packages"` - Skills []byte `json:"skills"` + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Name pgtype.Text `json:"name"` + NetworkAccess string `json:"network_access"` + NetworkAllowedDomains []string `json:"network_allowed_domains"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Files []byte `json:"files"` + Packages []byte `json:"packages"` + Skills []byte `json:"skills"` } func (q *Queries) ListEnvironmentTemplates(ctx context.Context, arg ListEnvironmentTemplatesParams) ([]ListEnvironmentTemplatesRow, error) { @@ -179,6 +186,7 @@ func (q *Queries) ListEnvironmentTemplates(ctx context.Context, arg ListEnvironm &i.TenantID, &i.Name, &i.NetworkAccess, + &i.NetworkAllowedDomains, &i.CreatedAt, &i.UpdatedAt, &i.Files, @@ -196,7 +204,7 @@ func (q *Queries) ListEnvironmentTemplates(ctx context.Context, arg ListEnvironm } const resolveEnvironmentTemplate = `-- name: ResolveEnvironmentTemplate :one -SELECT id, tenant_id, name, network_access, created_at, updated_at, files, file_contents, packages, env_contents, setup_contents, skills, skill_contents FROM environment_templates WHERE tenant_id = $1 AND id = $2 +SELECT id, tenant_id, name, network_access, created_at, updated_at, files, file_contents, packages, env_contents, setup_contents, skills, skill_contents, network_allowed_domains FROM environment_templates WHERE tenant_id = $1 AND id = $2 ` type ResolveEnvironmentTemplateParams struct { @@ -221,6 +229,7 @@ func (q *Queries) ResolveEnvironmentTemplate(ctx context.Context, arg ResolveEnv &i.SetupContents, &i.Skills, &i.SkillContents, + &i.NetworkAllowedDomains, ) return i, err } @@ -229,49 +238,52 @@ const updateEnvironmentTemplate = `-- name: UpdateEnvironmentTemplate :one UPDATE environment_templates SET name = CASE WHEN $1::boolean THEN $2::text ELSE name END, network_access = CASE WHEN $3::boolean THEN $4::text ELSE network_access END, - files = CASE WHEN $5::boolean THEN $6::jsonb ELSE files END, - file_contents = CASE WHEN $5::boolean THEN $7::bytea ELSE file_contents END, - packages = CASE WHEN $8::boolean THEN $9::jsonb ELSE packages END, - env_contents = CASE WHEN $10::boolean THEN $11::bytea ELSE env_contents END, - setup_contents = CASE WHEN $12::boolean THEN $13::bytea ELSE setup_contents END, - skills = CASE WHEN $14::boolean THEN $15::jsonb ELSE skills END, - skill_contents = CASE WHEN $14::boolean THEN $16::bytea ELSE skill_contents END, + network_allowed_domains = CASE WHEN $3::boolean THEN $5::text[] ELSE network_allowed_domains END, + files = CASE WHEN $6::boolean THEN $7::jsonb ELSE files END, + file_contents = CASE WHEN $6::boolean THEN $8::bytea ELSE file_contents END, + packages = CASE WHEN $9::boolean THEN $10::jsonb ELSE packages END, + env_contents = CASE WHEN $11::boolean THEN $12::bytea ELSE env_contents END, + setup_contents = CASE WHEN $13::boolean THEN $14::bytea ELSE setup_contents END, + skills = CASE WHEN $15::boolean THEN $16::jsonb ELSE skills END, + skill_contents = CASE WHEN $15::boolean THEN $17::bytea ELSE skill_contents END, updated_at = clock_timestamp() -WHERE tenant_id = $17 AND id = $18 -RETURNING id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills +WHERE tenant_id = $18 AND id = $19 +RETURNING id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills ` type UpdateEnvironmentTemplateParams struct { - SetName bool `json:"set_name"` - Name pgtype.Text `json:"name"` - SetNetwork bool `json:"set_network"` - NetworkAccess string `json:"network_access"` - SetFiles bool `json:"set_files"` - Files []byte `json:"files"` - FileContents []byte `json:"file_contents"` - SetPackages bool `json:"set_packages"` - Packages []byte `json:"packages"` - SetEnv bool `json:"set_env"` - EnvContents []byte `json:"env_contents"` - SetSetup bool `json:"set_setup"` - SetupContents []byte `json:"setup_contents"` - SetSkills bool `json:"set_skills"` - Skills []byte `json:"skills"` - SkillContents []byte `json:"skill_contents"` - TenantID pgtype.UUID `json:"tenant_id"` - ID pgtype.UUID `json:"id"` + SetName bool `json:"set_name"` + Name pgtype.Text `json:"name"` + SetNetwork bool `json:"set_network"` + NetworkAccess string `json:"network_access"` + NetworkAllowedDomains []string `json:"network_allowed_domains"` + SetFiles bool `json:"set_files"` + Files []byte `json:"files"` + FileContents []byte `json:"file_contents"` + SetPackages bool `json:"set_packages"` + Packages []byte `json:"packages"` + SetEnv bool `json:"set_env"` + EnvContents []byte `json:"env_contents"` + SetSetup bool `json:"set_setup"` + SetupContents []byte `json:"setup_contents"` + SetSkills bool `json:"set_skills"` + Skills []byte `json:"skills"` + SkillContents []byte `json:"skill_contents"` + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` } type UpdateEnvironmentTemplateRow struct { - ID pgtype.UUID `json:"id"` - TenantID pgtype.UUID `json:"tenant_id"` - Name pgtype.Text `json:"name"` - NetworkAccess string `json:"network_access"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - Files []byte `json:"files"` - Packages []byte `json:"packages"` - Skills []byte `json:"skills"` + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Name pgtype.Text `json:"name"` + NetworkAccess string `json:"network_access"` + NetworkAllowedDomains []string `json:"network_allowed_domains"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Files []byte `json:"files"` + Packages []byte `json:"packages"` + Skills []byte `json:"skills"` } func (q *Queries) UpdateEnvironmentTemplate(ctx context.Context, arg UpdateEnvironmentTemplateParams) (UpdateEnvironmentTemplateRow, error) { @@ -280,6 +292,7 @@ func (q *Queries) UpdateEnvironmentTemplate(ctx context.Context, arg UpdateEnvir arg.Name, arg.SetNetwork, arg.NetworkAccess, + arg.NetworkAllowedDomains, arg.SetFiles, arg.Files, arg.FileContents, @@ -301,6 +314,7 @@ func (q *Queries) UpdateEnvironmentTemplate(ctx context.Context, arg UpdateEnvir &i.TenantID, &i.Name, &i.NetworkAccess, + &i.NetworkAllowedDomains, &i.CreatedAt, &i.UpdatedAt, &i.Files, diff --git a/services/agents-api/internal/db/sqlc/models.go b/services/agents-api/internal/db/sqlc/models.go index f6c9a6fb4..68fac8faa 100644 --- a/services/agents-api/internal/db/sqlc/models.go +++ b/services/agents-api/internal/db/sqlc/models.go @@ -81,19 +81,20 @@ type EnvironmentSetup struct { } type EnvironmentTemplate struct { - ID pgtype.UUID `json:"id"` - TenantID pgtype.UUID `json:"tenant_id"` - Name pgtype.Text `json:"name"` - NetworkAccess string `json:"network_access"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - Files []byte `json:"files"` - FileContents []byte `json:"file_contents"` - Packages []byte `json:"packages"` - EnvContents []byte `json:"env_contents"` - SetupContents []byte `json:"setup_contents"` - Skills []byte `json:"skills"` - SkillContents []byte `json:"skill_contents"` + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Name pgtype.Text `json:"name"` + NetworkAccess string `json:"network_access"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Files []byte `json:"files"` + FileContents []byte `json:"file_contents"` + Packages []byte `json:"packages"` + EnvContents []byte `json:"env_contents"` + SetupContents []byte `json:"setup_contents"` + Skills []byte `json:"skills"` + SkillContents []byte `json:"skill_contents"` + NetworkAllowedDomains []string `json:"network_allowed_domains"` } type ExecutionProjectScope struct { diff --git a/services/agents-api/internal/execution/environment_placement.go b/services/agents-api/internal/execution/environment_placement.go index 85cdf4ccf..48b9e211c 100644 --- a/services/agents-api/internal/execution/environment_placement.go +++ b/services/agents-api/internal/execution/environment_placement.go @@ -8,6 +8,7 @@ import ( v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -18,6 +19,7 @@ type environmentPlacement struct { ToolEnvironment bool `json:"initialization,omitempty"` SystemPackages bool `json:"-"` NetworkAccess string `json:"-"` + AllowedDomains []string `json:"-"` WorkspaceDirectory string `json:"workspace_directory"` CapabilityDirectories []string `json:"capability_directories"` } @@ -40,7 +42,7 @@ func parseEnvironmentPlacement(configuration json.RawMessage) (environmentPlacem return placement, nil } case "openai_hosted": - // Qualified local execution currently supports enabled/disabled network only. + // Stored policy is shared by preparation and provider bootstrap. var local struct { Skills []agentskill.Metadata `json:"skills,omitempty"` Files []store.InitialFileMetadata `json:"files"` @@ -59,10 +61,11 @@ func parseEnvironmentPlacement(configuration json.RawMessage) (environmentPlacem placement.SystemPackages = local.Packages != nil && len(local.Packages.System) > 0 placement.NetworkAccess = "enabled" if local.Network != nil { - if len(local.Network.AllowedDomains) != 0 || (local.Network.Access != "enabled" && local.Network.Access != "disabled") { + if (agentnetwork.Policy{Access: local.Network.Access, AllowedDomains: local.Network.AllowedDomains}).Validate() != nil { return placement, store.ErrInvalidInput } placement.NetworkAccess = local.Network.Access + placement.AllowedDomains = append([]string(nil), local.Network.AllowedDomains...) } return placement, nil } @@ -90,18 +93,8 @@ func (d *Dispatcher) configurePreparedEnvironment(ctx context.Context, session s return nil, store.ErrInvalidInput } req.LocalEnvironment = &proto.LocalEnvironment{ID: environment.ID, ToolEnvironment: placement.ToolEnvironment, SystemPackages: placement.SystemPackages, Skills: placement.Skills} - // Keep the previously qualified explicit-disabled internal peer path intact. - // New bound-policy peers validate the exact policy during preparation. - boundPolicy := placement.NetworkAccess != "disabled" - if d.Registry != nil { - if peer, e := d.Registry.LookupDevice(bound.ID); e == nil { - info, found, known := peer.AgentKindStatus(session.Engine) - boundPolicy = boundPolicy || (known && found && info.Capabilities.LocalEnvironmentNetworkPolicy) - } - } - if boundPolicy { - req.LocalEnvironment.NetworkAccess = placement.NetworkAccess - } + req.LocalEnvironment.NetworkAccess = placement.NetworkAccess + req.LocalEnvironment.AllowedDomains = append([]string(nil), placement.AllowedDomains...) return nil, nil } if d.EnvironmentConnection == nil { diff --git a/services/agents-api/internal/execution/environment_placement_test.go b/services/agents-api/internal/execution/environment_placement_test.go index 73edf19e0..e35a331f4 100644 --- a/services/agents-api/internal/execution/environment_placement_test.go +++ b/services/agents-api/internal/execution/environment_placement_test.go @@ -2,6 +2,7 @@ package execution import ( "context" + "slices" "testing" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" @@ -39,6 +40,23 @@ func TestLocalEnvironmentRequiresQualifiedProfileAndExactAuthority(t *testing.T) } } +func TestNetworkPolicySurvivesPreparedBinding(t *testing.T) { + session := store.Session{ID: "session", TenantID: "tenant"} + for _, network := range []string{`{"access":"disabled"}`, `{"access":"restricted","allowed_domains":["Example.com","api.example.com"]}`} { + environment := store.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, + Configuration: []byte(`{"type":"openai_hosted","network":` + network + `}`)} + placement, err := parseEnvironmentPlacement(environment.Configuration) + if err != nil { + t.Fatal(err) + } + var req proto.PromptRequestPayload + _, err = (&Dispatcher{}).configurePreparedEnvironment(t.Context(), session, environment, store.ExecutionDevice{EnvironmentID: environment.ID}, &req) + if err != nil || req.LocalEnvironment == nil || req.LocalEnvironment.NetworkAccess != placement.NetworkAccess || !slices.Equal(req.LocalEnvironment.AllowedDomains, placement.AllowedDomains) { + t.Fatal("prepared binding lost policy", req.LocalEnvironment, err) + } + } +} + func TestSystemPackagesRemainRequiredInExecutionBinding(t *testing.T) { session := store.Session{ID: "session", TenantID: "tenant"} environment := store.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, diff --git a/services/agents-api/internal/execution/runtime_lifecycle.go b/services/agents-api/internal/execution/runtime_lifecycle.go index f336bac1e..0de84ff7f 100644 --- a/services/agents-api/internal/execution/runtime_lifecycle.go +++ b/services/agents-api/internal/execution/runtime_lifecycle.go @@ -133,7 +133,7 @@ func (r *runtimeLifecycle) provision(ctx context.Context, tenant, environment, p } info, err := provider.Create(ctx, sandbox.Bootstrap{ Reference: runtimeReference(owner), SessionID: owner.SessionID, DeviceID: owner.DeviceID, - CoreURL: r.config.CoreURL, Credential: token, NetworkAccess: placement.NetworkAccess, + CoreURL: r.config.CoreURL, Credential: token, NetworkAccess: placement.NetworkAccess, AllowedDomains: placement.AllowedDomains, }) if err != nil { // Explicit invalid/foreign bootstrap cannot become an authorized Runtime. diff --git a/services/agents-api/internal/execution/support.go b/services/agents-api/internal/execution/support.go index b6af76f22..946cb9ec0 100644 --- a/services/agents-api/internal/execution/support.go +++ b/services/agents-api/internal/execution/support.go @@ -112,7 +112,7 @@ func (p Policy) engineCapabilities(peer *gateway.Session, engine string, snapsho if !caps.Preparation || !caps.LocalEnvironment || !caps.WorkspaceReadPreparation || !caps.WorkspaceOutputExport { return fail("device must advertise local preparation, workspace reads and output export") } - if (snapshot.Environment.Network == nil || snapshot.Environment.Network.Access != "disabled") && !caps.LocalEnvironmentNetworkPolicy { + if !caps.LocalEnvironmentNetworkPolicy { return fail("device must advertise local_environment_network_policy") } } diff --git a/services/agents-api/internal/sandbox/docker/provider.go b/services/agents-api/internal/sandbox/docker/provider.go index 6e96c7911..73906c4f1 100644 --- a/services/agents-api/internal/sandbox/docker/provider.go +++ b/services/agents-api/internal/sandbox/docker/provider.go @@ -5,11 +5,13 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "errors" "fmt" "net/url" "strings" + "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/containerd/errdefs" "github.com/google/uuid" @@ -103,7 +105,8 @@ func (p *Provider) Renew(ctx context.Context, r sandbox.Reference) (sandbox.Info func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Info, error) { info := sandbox.Info{Reference: b.Reference} u, e := url.Parse(b.CoreURL) - if (b.NetworkAccess != "" && b.NetworkAccess != "enabled" && b.NetworkAccess != "disabled") || !validReference(b.Reference) || !validID(b.SessionID) || !validID(b.DeviceID) || e != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.TrimSpace(b.Credential) == "" { + policy := agentnetwork.Policy{Access: b.NetworkAccess, AllowedDomains: b.AllowedDomains} + if policy.Validate() != nil || !validReference(b.Reference) || !validID(b.SessionID) || !validID(b.DeviceID) || e != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.TrimSpace(b.Credential) == "" { return info, sandbox.ErrInvalid } if existing, e := p.GetInfo(ctx, b.Reference); e == nil { @@ -111,6 +114,10 @@ func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Inf } else if !errors.Is(e, sandbox.ErrNotFound) { return info, e } + domains, err := json.Marshal(policy.Hosts()) + if err != nil { + return info, sandbox.ErrInvalid + } name := p.name(b.Reference) // Retained volumes without a container are partial or lost state, not an // invitation to overwrite native history with a new bootstrap identity. @@ -147,7 +154,7 @@ func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Inf init = &enabled } v, e := p.client.ContainerCreate(ctx, client.ContainerCreateOptions{Name: name, Image: p.config.Image, - Config: &container.Config{User: "1000:1000", WorkingDir: "/environment/workspace", Labels: p.labels(b.Reference), Env: []string{"PARSAR_RUNTIME_ENVIRONMENT_ID=" + b.EnvironmentID, "PARSAR_RUNTIME_SESSION_ID=" + b.SessionID, "PARSAR_RUNTIME_NETWORK_ACCESS=" + b.NetworkAccess}}, + Config: &container.Config{User: "1000:1000", WorkingDir: "/environment/workspace", Labels: p.labels(b.Reference), Env: []string{"PARSAR_RUNTIME_ENVIRONMENT_ID=" + b.EnvironmentID, "PARSAR_RUNTIME_SESSION_ID=" + b.SessionID, "PARSAR_RUNTIME_NETWORK_ACCESS=" + policy.Access, "PARSAR_RUNTIME_ALLOWED_DOMAINS=" + string(domains)}}, HostConfig: &container.HostConfig{ReadonlyRootfs: true, CapDrop: []string{"ALL"}, SecurityOpt: []string{"no-new-privileges", "seccomp=" + p.config.Seccomp, "apparmor=unconfined"}, NetworkMode: container.NetworkMode(p.config.Network), ExtraHosts: p.config.ExtraHosts, MaskedPaths: masked, ReadonlyPaths: readonly, Init: init, Resources: container.Resources{PidsLimit: &limit, Memory: 2 * 1024 * 1024 * 1024, NanoCPUs: 2 * 1000000000}, Tmpfs: map[string]string{"/tmp": "rw,nosuid,nodev,size=128m"}, diff --git a/services/agents-api/internal/sandbox/docker/provider_test.go b/services/agents-api/internal/sandbox/docker/provider_test.go index 2db1b6910..c99ef6387 100644 --- a/services/agents-api/internal/sandbox/docker/provider_test.go +++ b/services/agents-api/internal/sandbox/docker/provider_test.go @@ -34,6 +34,22 @@ func TestProviderRejectsUnsafeOperatorConfiguration(t *testing.T) { } } +func TestBootstrapRequiresCompleteNetworkPolicyBeforeDockerEffects(t *testing.T) { + p := &Provider{} + for _, policy := range []sandbox.Bootstrap{ + {}, {NetworkAccess: "restricted"}, + {NetworkAccess: "restricted", AllowedDomains: []string{"*.example.com"}}, + {NetworkAccess: "enabled", AllowedDomains: []string{"example.com"}}, + } { + policy.Reference = sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()} + policy.SessionID, policy.DeviceID = uuid.NewString(), uuid.NewString() + policy.CoreURL, policy.Credential = "http://core.invalid/api/v1", "synthetic" + if _, err := p.Create(t.Context(), policy); !errors.Is(err, sandbox.ErrInvalid) { + t.Fatal("invalid bootstrap reached Docker", err) + } + } +} + // This optional Docker mechanism test uses a pinned fixture image whose entrypoint // is sleep. It is not native/model acceptance; the real Runtime has separate checks. func TestDockerProviderLifecycle(t *testing.T) { @@ -57,9 +73,10 @@ func TestDockerProviderLifecycle(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() bootstrap := func() sandbox.Bootstrap { - return sandbox.Bootstrap{Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "http://core.invalid/api/v1", Credential: "synthetic-test-credential"} + return sandbox.Bootstrap{Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "http://core.invalid/api/v1", Credential: "synthetic-test-credential", NetworkAccess: "enabled"} } b := bootstrap() + b.NetworkAccess, b.AllowedDomains = "restricted", []string{"Example.com", "api.example.com"} t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() @@ -101,6 +118,15 @@ func TestDockerProviderLifecycle(t *testing.T) { if strings.Contains(string(inspected.Raw), b.Credential) || inspected.Container.Config.User != "1000:1000" || !inspected.Container.HostConfig.ReadonlyRootfs || inspected.Container.HostConfig.Privileged { t.Fatal("unsafe Docker configuration") } + for _, value := range []string{"PARSAR_RUNTIME_NETWORK_ACCESS=restricted", `PARSAR_RUNTIME_ALLOWED_DOMAINS=["api.example.com","example.com"]`} { + found := false + for _, entry := range inspected.Container.Config.Env { + found = found || entry == value + } + if !found { + t.Fatalf("bootstrap lost network policy: %s", value) + } + } changed := b changed.Credential = "must-not-replace-existing" if _, e = p.Create(ctx, changed); !errors.Is(e, sandbox.ErrExists) { diff --git a/services/agents-api/internal/sandbox/docker/recovery_test.go b/services/agents-api/internal/sandbox/docker/recovery_test.go index e433af9f1..36df2e0d9 100644 --- a/services/agents-api/internal/sandbox/docker/recovery_test.go +++ b/services/agents-api/internal/sandbox/docker/recovery_test.go @@ -87,7 +87,7 @@ func TestDockerProviderRecoveryObservations(t *testing.T) { if e != nil { t.Fatal(e) } - b := sandbox.Bootstrap{Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "http://core.invalid/api/v1", Credential: "synthetic-recovery-token"} + b := sandbox.Bootstrap{Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "http://core.invalid/api/v1", Credential: "synthetic-recovery-token", NetworkAccess: "enabled"} direct, e := client.New(client.WithHost("unix:///var/run/docker.sock")) if e != nil { t.Fatal(e) diff --git a/services/agents-api/internal/sandbox/provider.go b/services/agents-api/internal/sandbox/provider.go index 485cbbe0b..6fbf34f91 100644 --- a/services/agents-api/internal/sandbox/provider.go +++ b/services/agents-api/internal/sandbox/provider.go @@ -24,6 +24,7 @@ type Bootstrap struct { Reference SessionID, DeviceID, CoreURL, Credential string NetworkAccess string + AllowedDomains []string } // Info describes compute only. Running does not establish daemon authentication, diff --git a/services/agents-api/internal/store/environment_network_test.go b/services/agents-api/internal/store/environment_network_test.go new file mode 100644 index 000000000..e0090fe3c --- /dev/null +++ b/services/agents-api/internal/store/environment_network_test.go @@ -0,0 +1,58 @@ +package store + +import ( + "errors" + "reflect" + "testing" + + "github.com/google/uuid" +) + +func TestTemplateNetworkPolicyRoundTripAndReplacement(t *testing.T) { + s, _ := testStore(t) + ctx := t.Context() + tenant, foreign := uuid.NewString(), uuid.NewString() + domains := []string{"Example.com", "api.example.com", "Example.com"} + created, err := s.CreateEnvironmentTemplate(ctx, tenant, EnvironmentTemplateInput{SetNetwork: true, NetworkAccess: "restricted", AllowedDomains: domains}) + if err != nil { + t.Fatal(err) + } + check := func(value EnvironmentTemplate, err error) { + t.Helper() + if err != nil || value.NetworkAccess != "restricted" || !reflect.DeepEqual(value.AllowedDomains, domains) { + t.Fatalf("network policy lost: %#v, %v", value, err) + } + } + check(created, nil) + check(s.GetEnvironmentTemplate(ctx, tenant, created.ID)) + resolved, _, err := s.ResolveEnvironmentTemplate(ctx, tenant, created.ID) + check(resolved, err) + page, err := s.ListEnvironmentTemplates(ctx, tenant, "", 1, true) + if err != nil || len(page.Templates) != 1 { + t.Fatal(page, err) + } + check(page.Templates[0], nil) + name := "renamed" + check(s.UpdateEnvironmentTemplate(ctx, tenant, created.ID, EnvironmentTemplateInput{SetName: true, Name: &name})) + if _, _, err := s.ResolveEnvironmentTemplate(ctx, foreign, created.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign policy resolution", err) + } + for _, in := range []EnvironmentTemplateInput{ + {SetNetwork: true, NetworkAccess: "enabled", AllowedDomains: domains}, + {SetNetwork: true, NetworkAccess: "restricted"}, + {SetNetwork: true, NetworkAccess: "restricted", AllowedDomains: []string{"*.example.com"}}, + } { + if _, err := s.UpdateEnvironmentTemplate(ctx, tenant, created.ID, in); !errors.Is(err, ErrInvalidInput) { + t.Fatal("invalid policy replacement", err) + } + check(s.GetEnvironmentTemplate(ctx, tenant, created.ID)) + } + domains = []string{"other.example.com"} + check(s.UpdateEnvironmentTemplate(ctx, tenant, created.ID, EnvironmentTemplateInput{SetNetwork: true, NetworkAccess: "restricted", AllowedDomains: domains})) + for _, access := range []string{"disabled", "enabled"} { + value, err := s.UpdateEnvironmentTemplate(ctx, tenant, created.ID, EnvironmentTemplateInput{SetNetwork: true, NetworkAccess: access}) + if err != nil || value.NetworkAccess != access || value.AllowedDomains == nil || len(value.AllowedDomains) != 0 { + t.Fatal("policy reset", value, err) + } + } +} diff --git a/services/agents-api/internal/store/environment_templates.go b/services/agents-api/internal/store/environment_templates.go index 52b17470b..31a7c9de9 100644 --- a/services/agents-api/internal/store/environment_templates.go +++ b/services/agents-api/internal/store/environment_templates.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" "time" "unicode/utf8" @@ -26,6 +27,7 @@ type EnvironmentTemplate struct { ID string Name *string NetworkAccess string + AllowedDomains []string CreatedAt time.Time UpdatedAt time.Time } @@ -38,12 +40,13 @@ type EnvironmentTemplateInput struct { Name *string SetName bool NetworkAccess string + AllowedDomains []string SetNetwork bool } func (in EnvironmentTemplateInput) valid() bool { return in.Initialization.Validate() == nil && (in.Name == nil || (utf8.ValidString(*in.Name) && utf8.RuneCountInString(*in.Name) >= 1 && utf8.RuneCountInString(*in.Name) <= 256)) && - (!in.SetNetwork || in.NetworkAccess == "enabled" || in.NetworkAccess == "disabled") + (!in.SetNetwork || (agentnetwork.Policy{Access: in.NetworkAccess, AllowedDomains: in.AllowedDomains}).Validate() == nil) } type templateMetadataRow sqlc.GetEnvironmentTemplateRow @@ -55,7 +58,7 @@ func templateFromRow(row templateMetadataRow, err error) (EnvironmentTemplate, e if err != nil { return EnvironmentTemplate{}, err } - result := EnvironmentTemplate{ID: uuid.UUID(row.ID.Bytes).String(), NetworkAccess: row.NetworkAccess, CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time} + result := EnvironmentTemplate{ID: uuid.UUID(row.ID.Bytes).String(), NetworkAccess: row.NetworkAccess, AllowedDomains: append([]string{}, row.NetworkAllowedDomains...), CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time} if row.Name.Valid { result.Name = &row.Name.String } @@ -68,6 +71,7 @@ func templateFromRow(row templateMetadataRow, err error) (EnvironmentTemplate, e func (s *Store) CreateEnvironmentTemplate(ctx context.Context, tenantID string, in EnvironmentTemplateInput) (EnvironmentTemplate, error) { if !in.SetNetwork { in.NetworkAccess = "enabled" + in.AllowedDomains = nil in.SetNetwork = true } if !in.valid() { @@ -94,7 +98,7 @@ func (s *Store) CreateEnvironmentTemplate(ctx context.Context, tenantID string, if err != nil { return EnvironmentTemplate{}, err } - row, err := s.queries.CreateEnvironmentTemplate(ctx, sqlc.CreateEnvironmentTemplateParams{ID: pgtype.UUID{Bytes: id, Valid: true}, TenantID: tenant, Name: name, NetworkAccess: in.NetworkAccess, Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, Skills: skills, SkillContents: skillContents}) + row, err := s.queries.CreateEnvironmentTemplate(ctx, sqlc.CreateEnvironmentTemplateParams{ID: pgtype.UUID{Bytes: id, Valid: true}, TenantID: tenant, Name: name, NetworkAccess: in.NetworkAccess, NetworkAllowedDomains: append([]string{}, in.AllowedDomains...), Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, Skills: skills, SkillContents: skillContents}) return templateFromRow(templateMetadataRow(row), err) } @@ -143,7 +147,7 @@ func (s *Store) UpdateEnvironmentTemplate(ctx context.Context, tenantID, templat if err != nil { return EnvironmentTemplate{}, err } - row, err := s.queries.UpdateEnvironmentTemplate(ctx, sqlc.UpdateEnvironmentTemplateParams{TenantID: tenant, ID: id, Name: name, SetName: in.SetName, NetworkAccess: in.NetworkAccess, SetNetwork: in.SetNetwork, SetFiles: in.SetFiles, Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, SetPackages: in.SetPackages, SetEnv: in.SetEnv, SetSetup: in.SetSetup, SetSkills: in.SetSkills, Skills: skills, SkillContents: skillContents}) + row, err := s.queries.UpdateEnvironmentTemplate(ctx, sqlc.UpdateEnvironmentTemplateParams{TenantID: tenant, ID: id, Name: name, SetName: in.SetName, NetworkAccess: in.NetworkAccess, NetworkAllowedDomains: append([]string{}, in.AllowedDomains...), SetNetwork: in.SetNetwork, SetFiles: in.SetFiles, Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, SetPackages: in.SetPackages, SetEnv: in.SetEnv, SetSetup: in.SetSetup, SetSkills: in.SetSkills, Skills: skills, SkillContents: skillContents}) return templateFromRow(templateMetadataRow(row), err) } diff --git a/services/agents-api/internal/store/initial_files.go b/services/agents-api/internal/store/initial_files.go index 61841fdce..f80238c3d 100644 --- a/services/agents-api/internal/store/initial_files.go +++ b/services/agents-api/internal/store/initial_files.go @@ -104,7 +104,7 @@ func (s *Store) ResolveEnvironmentTemplate(ctx context.Context, tenant, id strin return EnvironmentTemplate{}, nil, ErrNotFound } row, err := s.queries.ResolveEnvironmentTemplate(ctx, sqlc.ResolveEnvironmentTemplateParams{TenantID: lookup.TenantID, ID: lookup.ID}) - value, err := templateFromRow(templateMetadataRow{ID: row.ID, TenantID: row.TenantID, Name: row.Name, NetworkAccess: row.NetworkAccess, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, Files: row.Files, Packages: row.Packages, Skills: row.Skills}, err) + value, err := templateFromRow(templateMetadataRow{ID: row.ID, TenantID: row.TenantID, Name: row.Name, NetworkAccess: row.NetworkAccess, NetworkAllowedDomains: row.NetworkAllowedDomains, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, Files: row.Files, Packages: row.Packages, Skills: row.Skills}, err) if err != nil { return value, nil, err } diff --git a/services/agents-api/internal/store/local_environment_worker_test.go b/services/agents-api/internal/store/local_environment_worker_test.go index ed6a3b481..bce7b6ca0 100644 --- a/services/agents-api/internal/store/local_environment_worker_test.go +++ b/services/agents-api/internal/store/local_environment_worker_test.go @@ -22,6 +22,7 @@ func localWorker(t *testing.T, scoped, execute bool) (*dispatchHarness, *executi } caps := proto.AgentKindCapabilities{LocalEnvironment: true, Preparation: true, WorkspaceReadPreparation: true} if execute { + caps.LocalEnvironmentNetworkPolicy = true caps.WorkspaceOutputExport = true caps.Streaming, caps.Steering, caps.DurableTurns, caps.DurableInputReceipts = true, true, true, true caps.WebSearchControl, caps.TextVerbosity, caps.ExecutionControls = true, true, true diff --git a/services/agents-api/migrations/000047_environment_network_domains.sql b/services/agents-api/migrations/000047_environment_network_domains.sql new file mode 100644 index 000000000..b9e953444 --- /dev/null +++ b/services/agents-api/migrations/000047_environment_network_domains.sql @@ -0,0 +1,21 @@ +-- +goose Up +ALTER TABLE environment_templates + DROP CONSTRAINT environment_templates_network_access_check, + ADD COLUMN network_allowed_domains text[] NOT NULL DEFAULT '{}', + ADD CONSTRAINT environment_templates_network_access_check + CHECK (network_access IN ('enabled', 'disabled', 'restricted')), + ADD CONSTRAINT environment_templates_network_domains_check + CHECK ((network_access = 'restricted' AND cardinality(network_allowed_domains) BETWEEN 1 AND 100) + OR (network_access IN ('enabled', 'disabled') AND cardinality(network_allowed_domains) = 0)); + +-- +goose Down +-- Refuse rollback while restricted templates exist instead of broadening authority. +ALTER TABLE environment_templates + ADD CONSTRAINT environment_templates_legacy_network_check + CHECK (network_access IN ('enabled', 'disabled')); +ALTER TABLE environment_templates + DROP CONSTRAINT environment_templates_network_domains_check, + DROP CONSTRAINT environment_templates_network_access_check, + DROP COLUMN network_allowed_domains; +ALTER TABLE environment_templates + RENAME CONSTRAINT environment_templates_legacy_network_check TO environment_templates_network_access_check; diff --git a/services/agents-api/tests/official_environment_network.py b/services/agents-api/tests/official_environment_network.py new file mode 100644 index 000000000..1e3bfb1be --- /dev/null +++ b/services/agents-api/tests/official_environment_network.py @@ -0,0 +1,74 @@ +"""Restricted-policy assertions for a real standalone Core and fixed SDK. + +These checks establish resource behavior only. Native model/tool enforcement, +Files/Artifacts, cancellation and recovery need the real deployment driver. +""" + +NETWORK = {'access': 'restricted', 'allowed_domains': ['Example.com', 'httpbingo.org', 'example.com']} + + +def verify_network_resources(client, foreign, http, agent): + templates = client.beta.agents.environments.templates + root = str(client.base_url).rstrip('/') + headers = {'Authorization': 'Bearer ' + client.api_key, 'OpenAI-Beta': 'agents=v1'} + template = templates.create(network=NETWORK) + endpoint = root + '/agents/environments/templates/' + template.id + try: + assert template.network.to_dict() == NETWORK + assert templates.update(template.id, name='Network policy').network.to_dict() == NETWORK + assert http.get(endpoint, headers=headers).json()['network'] == NETWORK + assert next(item for item in templates.list() if item.id == template.id).network.to_dict() == NETWORK + foreign_headers = {**headers, 'Authorization': 'Bearer ' + foreign.api_key} + for method in ['GET', 'POST', 'DELETE']: + response = http.request(method, endpoint, headers=foreign_headers, + json={'network': {'access': 'enabled'}} if method == 'POST' else None) + assert response.status_code == 404 + for network in [ + {'access': 'restricted'}, {'access': 'restricted', 'allowed_domains': []}, + {'access': 'restricted', 'allowed_domains': None}, + {'access': 'restricted', 'allowed_domains': ['*.example.com']}, + {'access': 'restricted', 'allowed_domains': ['https://example.com']}, + {'access': 'restricted', 'allowed_domains': ['127.0.0.1']}, + {'access': 'restricted', 'allowed_domains': ['example.com'] * 101}, + {'access': 'enabled', 'allowed_domains': ['example.com']}, + ]: + for target in [endpoint, root + '/agents/environments/templates']: + assert http.post(target, headers=headers, json={'network': network}).status_code == 400 + assert templates.retrieve(template.id).network.to_dict() == NETWORK + for override in [{'access': 'enabled'}, {'access': 'restricted', 'allowed_domains': ['sub.example.com']}, + {'access': 'restricted', 'allowed_domains': ['example.org']}]: + response = http.post(root + '/agents/sessions', headers=headers, json={ + 'agent': agent, 'environment': {'type': 'openai_hosted', + 'environment_template_id': template.id, 'network': override}}) + assert response.status_code == 400 + response = http.post(root + '/agents/sessions', headers=foreign_headers, json={ + 'agent': agent, 'environment': {'type': 'openai_hosted', 'environment_template_id': template.id}}) + assert response.status_code == 404 + replacement = {'access': 'restricted', 'allowed_domains': ['httpbingo.org']} + assert templates.update(template.id, network=replacement).network.to_dict() == replacement + assert templates.update(template.id, network=None).network.to_dict() == {'access': 'enabled', 'allowed_domains': []} + assert templates.update(template.id, network={'access': 'disabled'}).network.to_dict() == {'access': 'disabled', 'allowed_domains': []} + finally: + templates.delete(template.id) + + +def attach_network(client, environment, template_id=None): + if template_id: + ceiling = {'access': 'restricted', 'allowed_domains': ['example.com', 'httpbingo.org', 'example.org']} + client.beta.agents.environments.templates.update(template_id, network=ceiling) + return {**environment, 'network': NETWORK} + + +def verify_network_snapshot(client, http, session, spec, key): + assert session.environment.network.to_dict() == NETWORK + sessions = client.beta.agents.sessions + assert sessions.retrieve(session.id).environment.network.to_dict() == NETWORK + retried = sessions.create(**spec, extra_headers={'Idempotency-Key': key}) + assert retried.id == session.id and retried.environment.network.to_dict() == NETWORK + changed = {**spec, 'environment': {**spec['environment'], 'network': { + 'access': 'restricted', 'allowed_domains': ['httpbingo.org', 'example.com']}}} + # Equal effective authority does not erase the caller's original array identity. + response = http.post(str(client.base_url).rstrip('/') + '/agents/sessions', json=changed, + headers={'Authorization': 'Bearer ' + client.api_key, + 'OpenAI-Beta': 'agents=v1', 'Idempotency-Key': key}) + assert response.status_code == 409 diff --git a/services/agents-api/tests/official_environment_templates.py b/services/agents-api/tests/official_environment_templates.py index 35428d214..754726ce5 100644 --- a/services/agents-api/tests/official_environment_templates.py +++ b/services/agents-api/tests/official_environment_templates.py @@ -19,6 +19,7 @@ def verify_environment_templates(client, foreign, http): assert http.get(base, headers={'Authorization': 'Bearer ' + client.api_key}).status_code == 400 assert api.list().data == [] for values in ({}, {'packages': {}}, {'packages': {'npm': None}}, {'name': None, 'network': None, 'env': None, 'setup_commands': None}, + {'network': {'access': 'restricted', 'allowed_domains': ['Example.com', 'api.example.com', 'example.com']}}, {'name': ' preserved ', 'network': {'access': 'disabled'}, 'files': [], 'plugins': [], 'skills': [], 'packages': {'python': [], 'npm': None}}): response = api.with_raw_response.create(**values) @@ -29,7 +30,7 @@ def verify_environment_templates(client, foreign, http): assert body['object'] == 'agent.environment.template' assert body['name'] == values.get('name') assert body['network'] == {'access': (values.get('network') or {}).get('access', 'enabled'), - 'allowed_domains': []} + 'allowed_domains': (values.get('network') or {}).get('allowed_domains') or []} assert body['packages'] == {'python': [], 'npm': [], 'system': []} for field in ['capability_directories', 'files', 'plugins', 'skills']: assert body[field] == [] @@ -66,7 +67,7 @@ def verify_environment_templates(client, foreign, http): {'packages': {'system': ['-' + canary]}}, {'skills': [{'type': 'inline', 'data': canary}]}, {'plugins': [{'type': 'inline', 'data': canary}]}, {'capability_directories': ['/workspace']}, - {'network': {'access': 'restricted', 'allowed_domains': ['example.com']}}, + {'network': {'access': 'restricted', 'allowed_domains': ['*.example.com']}}, {'name': ''}, {'unknown': canary}]: for path in ['', '/' + owned[0]]: response = http.post(base + path, headers=headers, json=body)