Files
OpenShell/proto/sandbox.proto
Derek Carr 5952a5a23f feat(workspace): add workspace resource model with scoping, membershi… (#2243)
* feat(workspace): implement workspace model (Phase 1 of RFC 0011)

Implements workspace and membership model providing hard isolation
boundaries for multi-player OpenShell deployments.

Workspace CRUD with Kubernetes-style Terminating phase for graceful
deletion. All resources scoped by workspace via ObjectMeta. Membership
RPCs for workspace access control. Persistence migration shifts name
uniqueness to (object_type, workspace, name). Provider profiles support
platform and workspace scoping. Service routing uses workspace-prefixed
DNS labels. Inference routes renamed and workspace-scoped with
DeleteInferenceRoute RPC. Python SDK with WorkspaceClient, two-method
list pattern (workspace-scoped and for_all_workspaces), and workspace
parameter on all methods. CLI workspace flags, TUI workspace cycling.
K8s driver filters unmanaged CRs and uses delete preconditions. Podman
driver uses immutable container IDs. Label serialization fixed across
all put_if call sites.

Signed-off-by: Derek Carr <decarr@redhat.com>

* fix(cli): delegate sandbox upload command to existing upload function

The standalone `sandbox upload` command reimplemented upload logic
inline with two bugs: it used `Path::exists()` which follows symlinks
(rejecting dangling symlinks), and it ran git-aware filtering on
symlink sources. The `run::sandbox_upload()` function already handles
both cases correctly via `sandbox_upload_plan()`. Replace the inline
logic with a call to the existing function.

Signed-off-by: Derek Carr <decarr@redhat.com>

* fix(e2e): shorten sandbox names and fix test compatibility

Shorten the sandbox name in initial_sparse_policy_is_acknowledged_as_loaded
from 'e2e-2159-sparse-enrich' (22 chars) to 'e2e-sparse-enrich' (17 chars)
to comply with MAX_ROUTABLE_NAME_LEN (19 chars).

Also capture stderr in create_keep_with_args so future sandbox creation
failures include the actual CLI error instead of reporting empty output.

Signed-off-by: Derek Carr <decarr@redhat.com>

* test(workspace): add test coverage for workspace CRUD and persistence isolation

Add unit tests for workspace create happy path, get round-trip, get
not-found, get empty-name rejection, already-exists error, and
resolve_workspace not-found. Add persistence test proving cross-workspace
name uniqueness (same name in different workspaces produces separate
records). Add workspace name max-length boundary tests. Fix e2e harness
to include stderr in name-parse-failure error path. Align Python e2e
test_workspace_crud with try/finally pattern. Document provider profile
catalog workspace scoping gap in RFC 0011.

Signed-off-by: Derek Carr <decarr@redhat.com>

* fix(examples): update examples for workspace model compatibility

Shorten sandbox names in demo scripts to fit the 19-character
MAX_ROUTABLE_NAME_LEN limit: policy-demo prefix to pd-, multi-agent
notepad derives a short SANDBOX_TAG from the run ID, governance
interceptor uses gs-PID-RANDOM. Update vscode-remote-sandbox.md SSH
host aliases from openshell-{name} to openshell-{name}.{workspace}
format.

Signed-off-by: Derek Carr <decarr@redhat.com>

* feat(sdk): add workspace-scoped client and workspace CRUD

Add WorkspaceScopedClient modeled after kube::Api::namespaced — captures
workspace once and injects it into every sandbox request. Add workspace
CRUD methods (create, get, list, delete) and list_sandboxes_all_workspaces
on OpenShellClient. Extend SandboxRef with workspace field and add
WorkspaceRef type. Include mock tests for all new operations.

Signed-off-by: Derek Carr <decarr@redhat.com>

* fix(lint): resolve clippy warnings in workspace test assertions

Signed-off-by: Derek Carr <decarr@redhat.com>

* fix(docs): convert indented code blocks to fenced in RFC 0011

Signed-off-by: Derek Carr <decarr@redhat.com>

* fix(lint): resolve clippy warnings and apply cargo fmt across workspace

Auto-format with cargo fmt and fix clippy warnings exposed by the
reformat: unnecessary qualifications, map_unwrap_or, identical match
arms, unused variable prefix, dead code annotations, and let-unit-value
in e2e harness.

Signed-off-by: Derek Carr <decarr@redhat.com>

* fix(workspace): address workspace scoping issues from review

- Add workspace field to settings JSON output (CLI)
- Skip Podman containers missing workspace label instead of defaulting
  to empty string, matching K8s driver behavior
- Add resource_version to list_by_scope SELECT in both SQLite and
  Postgres backends, with regression test
- Gate PolicyLocalContext proposal/lookup routes on workspace readiness,
  returning 503 when workspace is not yet discovered
- Block sandbox and provider creation in TUI all-workspaces mode
- Clear workspace vectors in TUI reset_sandbox_state

Signed-off-by: Derek Carr <decarr@redhat.com>

* fix(workspace): make provider profile catalog workspace-aware

Thread workspace through snapshot_catalog so the
EffectiveProviderProfileCatalog enforces workspace boundaries on both
read and write paths. UserProviderProfileSource now loads platform-scoped
profiles (workspace "") plus the target workspace's profiles, preventing
cross-workspace duplicate profile ID collisions that previously caused
global catalog failures.

Update RFC 0011 to reflect catalog scoping is implemented in Phase 1
rather than deferred to future work.

Signed-off-by: Derek Carr <decarr@redhat.com>

* fix(persistence): include workspace column in atomic policy revision INSERT

put_policy_revision_atomic omitted the workspace column from the INSERT
into the objects table in both SQLite and Postgres backends, causing
atomically-written policy revisions to lose their workspace association.
Add workspace field to AtomicPolicyRevisionWrite and thread it through
both backend INSERT statements, matching the non-atomic put_policy_revision
path which already included it.

Signed-off-by: Derek Carr <decarr@redhat.com>

* fix(proxy): skip ancestor walk when socket owner is the entrypoint

collect_ancestor_identities walked the entire process tree above the
entrypoint when the connecting process was the entrypoint itself,
SHA256-hashing every ancestor binary (IDE, shell, container runtime).
On dev machines with large binaries in the ancestor chain this exceeded
the 30-second test timeout. When start_pid == stop_pid there are no
intermediate ancestors to verify, so return an empty list immediately.

Signed-off-by: Derek Carr <decarr@redhat.com>

* fix(workspace): make provider profile catalog scope-aware

Allow the same profile ID at platform and workspace scopes by
introducing layered catalog entries where workspace profiles shadow
platform profiles. Add source and scope fields to the ProviderProfile
proto and CLI output. Migrate List/Get handlers to the catalog,
fixing divergence with runtime profile resolution.

Signed-off-by: Derek Carr <decarr@redhat.com>

* fix(e2e): align podman e2e labels with centralized driver constants

The podman driver moved its container labels to the centralized
openshell.ai/ prefix, but the e2e test harness and cleanup script
still referenced the old openshell.sandbox-* keys, causing the
local_driver_token_restart test to fail on container lookup.

Signed-off-by: Derek Carr <decarr@redhat.com>

* fix(e2e): align python profile isolation test with scope-aware catalog

Platform profiles are now visible in workspace listings as fallbacks
per the layered catalog design. Update the assertion to match.

Signed-off-by: Derek Carr <decarr@redhat.com>

* fix(workspace): honor profile_workspace in runtime profile resolution

Runtime profile lookups now consult provider.profile_workspace via
get_type_profile_for_scope. Providers created with --global-profile
(profile_workspace="") resolve to the platform profile even when a
workspace profile shadows the same ID. All 6 runtime call sites
updated; type-only call sites remain scope-agnostic.

Signed-off-by: Derek Carr <decarr@redhat.com>

---------

Signed-off-by: Derek Carr <decarr@redhat.com>
2026-07-21 00:59:18 +00:00

386 lines
16 KiB
Protocol Buffer

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
syntax = "proto3";
package openshell.sandbox.v1;
import "google/protobuf/struct.proto";
// Sandbox-supervisor configuration and policy messages.
//
// Conventions:
// - This file owns messages exchanged between the gateway and the sandbox
// supervisor/runtime.
// - Public sandbox resource types live in `openshell.proto`.
// - Internal compute-driver sandbox observation types live in `compute_driver.proto`.
// Sandbox security policy configuration.
message SandboxPolicy {
// Policy version.
uint32 version = 1;
// Filesystem access policy.
FilesystemPolicy filesystem = 2;
// Landlock configuration.
LandlockPolicy landlock = 3;
// Process execution policy.
ProcessPolicy process = 4;
// Network access policies keyed by name (e.g. "claude_code", "gitlab").
map<string, NetworkPolicyRule> network_policies = 5;
// Reusable supervisor middleware configs for network egress, keyed by their
// policy-local names. At most 10 configs are accepted, and at most 10 stages
// can be selected per request.
map<string, NetworkMiddlewareConfig> network_middlewares = 6;
}
// Filesystem access policy.
message FilesystemPolicy {
// Automatically include the workdir as read-write.
bool include_workdir = 1;
// Read-only directory allow list.
repeated string read_only = 2;
// Read-write directory allow list.
repeated string read_write = 3;
}
// Landlock policy configuration.
message LandlockPolicy {
// Compatibility mode (e.g. "best_effort", "hard_requirement").
string compatibility = 1;
}
// Process execution policy.
message ProcessPolicy {
// User name to run the sandboxed process as.
string run_as_user = 1;
// Group name to run the sandboxed process as.
string run_as_group = 2;
}
// A named network access policy rule.
message NetworkPolicyRule {
// Human-readable name for this policy rule.
string name = 1;
// Allowed endpoint (host:port) pairs.
repeated NetworkEndpoint endpoints = 2;
// Allowed binary identities.
repeated NetworkBinary binaries = 3;
}
// A reusable middleware config selected for admitted egress by host.
message NetworkMiddlewareConfig {
// Human-readable name for this middleware config.
string name = 1;
// Built-in middleware name or operator-owned registration name.
string middleware = 2;
// Service-specific configuration.
google.protobuf.Struct config = 3;
// Failure behavior: "fail_closed" (default) or "fail_open".
string on_error = 4;
// Host selector controlling which admitted destinations use this config.
MiddlewareEndpointSelector endpoints = 5;
// Execution order. Values must be unique within a policy; lower values run first.
int32 order = 6;
}
// Host selector controlling which admitted destinations use a middleware config.
message MiddlewareEndpointSelector {
// Exact host or DNS glob patterns included in the selection. Include and
// exclude accept at most 32 combined patterns.
repeated string include = 1;
// Exact host or DNS glob patterns removed from the selection.
// Exclusions take precedence over inclusions.
repeated string exclude = 2;
}
// A network endpoint (host + port) with optional L7 inspection config.
message NetworkEndpoint {
// Hostname or host glob pattern. Exact match is case-insensitive.
// Glob patterns use "." as delimiter: "*.example.com" matches a single
// subdomain label, "**.example.com" matches across labels.
string host = 1;
// Single port (backwards compat). Use `ports` for multiple ports.
// Mutually exclusive with `ports` — if both are set, `ports` takes precedence.
uint32 port = 2;
// Application protocol for L7 inspection: "rest", "websocket", "graphql", "sql", or "" (L4-only).
string protocol = 3;
// TLS handling: "terminate" or "passthrough" (default).
string tls = 4;
// Enforcement mode: "enforce" or "audit" (default).
string enforcement = 5;
// Access preset shorthand: "read-only", "read-write", "full".
// Mutually exclusive with rules.
string access = 6;
// Explicit L7 rules (mutually exclusive with access).
repeated L7Rule rules = 7;
// Allowed resolved IP addresses or CIDR ranges for this endpoint.
// When non-empty, the SSRF internal-IP check is replaced by an allowlist check:
// - If host is also set: domain must resolve to an IP in this list.
// - If host is empty: any domain is allowed as long as it resolves to an IP in this list.
// Supports exact IPs ("10.0.5.20") and CIDR notation ("10.0.5.0/24").
// Loopback (127.0.0.0/8) and link-local (169.254.0.0/16) are always blocked
// regardless of this field.
repeated string allowed_ips = 8;
// Multiple ports. When non-empty, this endpoint covers all listed ports.
// If `port` is set and `ports` is empty, `port` is normalized to `ports: [port]`.
// If both are set, `ports` takes precedence.
repeated uint32 ports = 9;
// Explicit L7 deny rules. When present, requests matching any deny rule
// are blocked even if they match an allow rule or access preset.
// Deny rules take precedence over allow rules.
repeated L7DenyRule deny_rules = 10;
// When true, percent-encoded '/' (%2F) is preserved in path segments
// rather than rejected by the L7 path canonicalizer. Required for
// upstreams like GitLab that embed %2F in namespaced resource paths.
// Defaults to false (strict).
bool allow_encoded_slash = 11;
// GraphQL persisted-query behavior for hash-only/saved-query requests:
// "deny" (default) or "allow_registered".
string persisted_queries = 12;
// Trusted GraphQL persisted-query registry keyed by hash or service-specific ID.
// Only used when persisted_queries is "allow_registered".
map<string, GraphqlOperation> graphql_persisted_queries = 13;
// Maximum GraphQL request body bytes to buffer for inspection.
// Defaults to 65536 when unset.
uint32 graphql_max_body_bytes = 14;
// Optional HTTP path glob that scopes this L7 endpoint on shared host:port APIs.
// Example: use path "/graphql" for protocol "graphql" and "/repos/**" for
// protocol "rest" when both surfaces live under api.example.com:443.
// Empty means all paths.
string path = 15;
// When true on a "rest" endpoint, OpenShell rewrites credential placeholders
// inside client-to-server WebSocket text messages after an allowed HTTP 101
// upgrade. Defaults to false.
bool websocket_credential_rewrite = 16;
// When true on a "rest" endpoint, OpenShell rewrites credential placeholders
// inside supported textual HTTP request bodies before forwarding upstream.
// Defaults to false.
bool request_body_credential_rewrite = 17;
// Internal provenance marker for policy-advisor generated endpoints.
// Advisor-proposed endpoints must not satisfy exact-host SSRF trust unless
// they are converted through an explicit user-authored policy path.
bool advisor_proposed = 18;
// Proxy-side credential signing mode: "sigv4" for AWS SigV4 re-signing.
// When set, the proxy strips the client's Authorization header and computes
// a fresh SigV4 signature using real credentials from the provider.
string credential_signing = 19;
// AWS signing service name override. Required when credential_signing is
// "sigv4" — e.g. "bedrock" for bedrock-runtime endpoints.
string signing_service = 20;
// AWS region override for SigV4 signing. When set, takes precedence over
// hostname-based region extraction. Required for non-standard endpoints.
string signing_region = 21;
// Maximum JSON-RPC-over-HTTP request body bytes to buffer for inspection.
// Defaults to 65536 when unset.
uint32 json_rpc_max_body_bytes = 22;
// MCP-only policy and inspection options. Only used when protocol is "mcp".
McpOptions mcp = 23;
}
// MCP options are grouped so MCP-specific policy can grow without adding more
// top-level NetworkEndpoint fields. Current enforcement targets the active
// 2025-11-25 Streamable HTTP/tools behavior, while preserving space for
// version-profile policy if OpenShell adopts 2026-07-28 draft behavior later.
//
// Planned policy extensions should use OpenShell-owned static definitions for
// MCP method/version profiles rather than treating dependency enums as the
// policy contract. Candidate profile checks include request metadata/header
// validation, response/SSE introspection, trusted annotation handling,
// resultType/cache metadata validation, x-mcp-header tool-definition checks,
// and subscriptions/listen handling.
//
// Sources:
// - https://modelcontextprotocol.io/specification/2025-11-25/server/tools
// - https://modelcontextprotocol.io/specification/draft/changelog
// - https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http
// - https://modelcontextprotocol.io/specification/draft/server/tools
message McpOptions {
// Hardening boundary for tools/call params.name. When unset or true, the
// supervisor enforces the MCP recommended tool-name syntax
// ^[A-Za-z0-9_.-]{1,128}$ before policy evaluation. Set false only for
// compatibility with servers that intentionally use non-recommended names.
//
// Source:
// - https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names
optional bool strict_tool_names = 1;
// Method-layer default for MCP endpoints. When true, OpenShell allows parsed
// MCP-family methods at the method layer unless a tool-name policy narrows
// tools/call. When unset or false, explicit method rules are required.
optional bool allow_all_known_mcp_methods = 2;
}
// Trusted GraphQL operation classification.
message GraphqlOperation {
// Operation type: "query", "mutation", or "subscription".
string operation_type = 1;
// Operation name, if known.
string operation_name = 2;
// Root field names selected by the operation.
repeated string fields = 3;
}
// An L7 deny rule that blocks specific requests.
// Mirrors L7Allow — same fields, same matching semantics, inverted effect.
// Deny rules are evaluated after allow rules and take precedence.
message L7DenyRule {
// Protocol method: HTTP method (REST/WebSocket), JSON-RPC method name, or
// "*" for any when supported by the protocol.
string method = 1;
// URL path glob pattern (REST): "/repos/*/pulls/*/reviews", "**" for any.
string path = 2;
// SQL command (SQL): SELECT, INSERT, etc. or "*" for any.
string command = 3;
// Query parameter matcher map (REST).
// Same semantics as L7Allow.query.
map<string, L7QueryMatcher> query = 4;
// GraphQL operation type: "query", "mutation", "subscription", or "*" for any.
string operation_type = 5;
// GraphQL operation name glob. "*" matches any operation name.
string operation_name = 6;
// GraphQL root field globs. Deny rules match when any selected root field
// matches any configured glob.
repeated string fields = 7;
reserved 8;
// MCP params matcher map. Currently only params.name is supported for
// tools/call filtering. Generic protocol "json-rpc" rejects params matchers.
map<string, L7QueryMatcher> params = 9;
}
// An L7 policy rule (allow-only).
message L7Rule {
L7Allow allow = 1;
}
// Allowed action definition for L7 rules.
message L7Allow {
// Protocol method: HTTP method (REST/WebSocket), JSON-RPC method name, or
// "*" for any when supported by the protocol.
string method = 1;
// URL path glob pattern (REST): "/repos/**", "**" for any.
string path = 2;
// SQL command (SQL): SELECT, INSERT, etc. or "*" for any.
string command = 3;
// Query parameter matcher map (REST).
// Key is the decoded query parameter name (case-sensitive).
// Value supports either a single glob (`glob`) or a list (`any`).
map<string, L7QueryMatcher> query = 4;
// GraphQL operation type: "query", "mutation", "subscription", or "*" for any.
string operation_type = 5;
// GraphQL operation name glob. "*" matches any operation name.
string operation_name = 6;
// GraphQL root field globs. Allow rules match only when every selected root
// field matches one of the configured globs. Omit to match all fields.
repeated string fields = 7;
reserved 8;
// MCP params matcher map. Currently only params.name is supported for
// tools/call filtering. Generic protocol "json-rpc" rejects params matchers.
map<string, L7QueryMatcher> params = 9;
}
// Query value matcher for one query parameter key.
message L7QueryMatcher {
// Single glob pattern.
string glob = 1;
// Any-of glob patterns.
repeated string any = 2;
}
// A binary identity for network policy matching.
message NetworkBinary {
string path = 1;
// Deprecated: the harness concept has been removed. This field is ignored.
bool harness = 2 [deprecated = true];
}
// Request to get sandbox settings by sandbox ID.
message GetSandboxConfigRequest {
// The sandbox ID.
string sandbox_id = 1;
}
// Request to get gateway-global settings.
message GetGatewayConfigRequest {}
// Response containing gateway-global settings.
message GetGatewayConfigResponse {
// Gateway-global settings map excluding the reserved policy key.
// Registered keys without a configured value are returned with an empty SettingValue.
map<string, SettingValue> settings = 1;
// Monotonically increasing revision for gateway-global settings.
uint64 settings_revision = 2;
}
// Scope that currently controls a setting.
enum SettingScope {
SETTING_SCOPE_UNSPECIFIED = 0;
SETTING_SCOPE_SANDBOX = 1;
SETTING_SCOPE_GLOBAL = 2;
}
// Type-aware setting value for sandbox/gateway settings.
message SettingValue {
oneof value {
string string_value = 1;
bool bool_value = 2;
int64 int_value = 3;
bytes bytes_value = 4;
}
}
// Effective setting value and the scope it was resolved from.
message EffectiveSetting {
SettingValue value = 1;
SettingScope scope = 2;
}
// Source used for the policy payload in GetSandboxConfigResponse.
enum PolicySource {
POLICY_SOURCE_UNSPECIFIED = 0;
POLICY_SOURCE_SANDBOX = 1;
POLICY_SOURCE_GLOBAL = 2;
}
// Response containing effective sandbox settings and policy.
message GetSandboxConfigResponse {
// The sandbox policy configuration.
SandboxPolicy policy = 1;
// Current policy version (monotonically increasing per sandbox).
uint32 version = 2;
// SHA-256 hash of the serialized policy payload.
string policy_hash = 3;
// Effective settings resolved for this sandbox, excluding the reserved policy key.
// Registered keys without a configured value are returned with an empty EffectiveSetting.value.
map<string, EffectiveSetting> settings = 4;
// Fingerprint for effective config (policy + settings). Changes when any effective input changes.
uint64 config_revision = 5;
// Source of the policy payload for this response.
PolicySource policy_source = 6;
// When policy_source is GLOBAL, the version of the global policy revision.
// Zero when no global policy is active or when policy_source is SANDBOX.
uint32 global_policy_version = 7;
// Fingerprint for provider credential inputs attached to this sandbox.
// Changes when attached provider names or attached provider records change.
uint64 provider_env_revision = 8;
// Operator-registered supervisor middleware services required by the
// effective policy. Built-in middleware is not included.
repeated SupervisorMiddlewareService supervisor_middleware_services = 9;
// Workspace the sandbox belongs to. Allows the supervisor to learn its
// workspace context for subsequent workspace-scoped RPCs.
string workspace = 10;
}
// Connection details for one operator-registered supervisor middleware service.
// V1 supports plaintext and server-authenticated TLS gRPC.
message SupervisorMiddlewareService {
// Operator-owned registration name used by policy attachments and diagnostics.
string name = 1;
// gRPC endpoint reachable from the sandbox supervisor.
string grpc_endpoint = 2;
// Operator-owned body limit applied to every binding exposed by the service.
uint64 max_body_bytes = 3;
// Default RPC timeout for this service. Empty uses the platform default of
// 500ms. Values use an integer with an `ms` or `s` suffix and must be
// between 10ms and 30s.
string timeout = 4;
}