Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions .agents/skills/generate-sandbox-policy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ For this tier, default to:
- `access: read-only` when the user says "read", "browse", "view", "query", "fetch"
- `access: read-write` when the user says "read-write", "create", "update" (but not "delete")
- `access: full` when the user says "full access", "everything", "unrestricted"
- L4-only (no `protocol`) when the user says "just allow it", "pass through", "no inspection"
- L4-only (omit `protocol`, or use explicit `protocol: tcp`) when the user says
"just allow it", "pass through", "no inspection". Prefer omission unless the
user wants the transport intent stated explicitly. Explicit TCP requires a
valid DNS hostname; omit `protocol` for a legacy hostless `allowed_ips`
proxy endpoint.

### Moderate Tier (host + partial path knowledge)

Expand Down Expand Up @@ -188,7 +192,7 @@ Follow this decision tree based on the detail tier and user intent:
```
Is L7 inspection needed?
├─ No (user wants pass-through / "just allow it")
│ └─ Generate L4-only policy (no protocol, no tls, no rules/access)
│ └─ Generate L4-only policy (no protocol, or protocol: tcp with a DNS hostname; no tls/rules/access)
└─ Yes (user wants method/path control)
Expand Down Expand Up @@ -376,7 +380,10 @@ Before presenting the policy to the user, verify correctness **and** flag breadt
### Hard Errors (would block sandbox startup)

- [ ] `rules` and `access` are NOT both present on the same endpoint
- [ ] If `protocol` is set, either `rules` or `access` is also present
- [ ] If an L7 `protocol` is set, either `rules` or `access` is also present;
`protocol: tcp` is L4-only and must not contain either field
- [ ] Every `protocol: tcp` endpoint has a valid DNS hostname; it is not
hostless, an IP literal, a trailing-dot name, or a malformed DNS selector
- [ ] If `tls: terminate` is set, `protocol` is also set
- [ ] `rules` list is not empty when present
- [ ] If `protocol: sql`, `enforcement` is not `enforce`
Expand Down Expand Up @@ -408,14 +415,14 @@ Evaluate the generated policy for overly broad access and **include warnings in

| Condition | Warning to show |
|-----------|----------------|
| **L4-only** (no `protocol`) | "This policy allows all HTTP methods and paths without inspection. The proxy will only check host:port and binary identity. Consider adding `protocol: rest` with a preset if you want method-level control." |
| **L4-only** (no `protocol`, or `protocol: tcp`) | "This policy allows all HTTP methods and paths without inspection. The proxy will only check host:port and binary identity. Consider adding `protocol: rest` with a preset if you want method-level control." |
| **`access: full`** | "This policy allows all HTTP methods (including DELETE) on all paths. If you don't need DELETE, `read-write` is safer. If you only need to read, `read-only` is the most restrictive option." |
| **`access: full` + `enforcement: audit`** | "Full access in audit mode provides no actual restriction — all traffic flows through. This is effectively a monitoring-only policy." |
| **`access: read-write`** when user hasn't confirmed write need | "This policy allows POST, PUT, and PATCH on all paths. If you only need to read data, `read-only` is more restrictive." |
| **Wildcard binary** (`*` or `**` in binary path) | "This policy allows any binary matching the glob pattern. A compromised or unexpected binary in that directory could use this policy. Consider listing specific binary paths." |
| **`**` path glob** on all explicit rules | "All rules use `**` path patterns, which match any URL path. This is equivalent to a preset — consider using `access: read-only` (or similar) for clarity, or narrowing paths if you know the API structure." |
| **Multiple broad endpoints** in one policy | "This policy grants the same broad access to N different hosts. If any of these hosts needs tighter restrictions later, you'll need to split the policy." |
| **Hostless `allowed_ips`** (no `host` field) | "This endpoint has no `host` — any domain resolving to the allowed IP range on this port will be permitted. Consider adding a `host` field to restrict which domains can use this allowlist." |
| **Hostless `allowed_ips`** (no `host` field and no `protocol: tcp`) | "This endpoint has no `host` — any domain resolving to the allowed IP range on this port will be permitted through the legacy proxy. Consider adding a `host` field to restrict which domains can use this allowlist." |
| **Broad CIDR** in `allowed_ips` (e.g., `10.0.0.0/8`) | "This `allowed_ips` entry covers a very broad range. Consider narrowing to a specific subnet (e.g., `10.0.5.0/24`) to minimize exposure." |
| **`on_error: fail_open`** | "This middleware can be bypassed when it is unavailable, rejects configuration, returns an invalid result, or exceeds its body limit. Use `fail_closed` unless availability is more important than this control." |
| **Broad middleware host selector** | "This middleware attaches independently of the admitting network rule to every matching destination, then runs only for operation bindings its implementation advertises. Narrow `endpoints.include` or add exclusions if the attachment is not required for every matching host." |
Expand Down
5 changes: 5 additions & 0 deletions .agents/skills/openshell-cli/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,11 @@ Notes:

- The sandbox name defaults to the last-used sandbox.
- `--add-endpoint` options are comma-separated: `allowed-ip=<CIDR-or-IP>`, `websocket-credential-rewrite`, `request-body-credential-rewrite`, and `allow-uninspected-credentials`. The last option is a security-sensitive exception for provider-credentialed L4-only, `tls: skip`, or otherwise uninspectable traffic.
- `protocol` accepts `tcp` for explicit L4-only host/port policy. It has the
same payload-handling behavior as omitting the protocol, but it requires a
valid DNS hostname and rejects hostless `allowed_ips` or literal-IP
selectors. It cannot be combined with `access`, `rules`, or L7 enforcement
options.
- `--add-allow` and `--add-deny` operate on REST and WebSocket endpoints. Use full YAML for JSON-RPC, MCP, SQL, or other policy structure.
- `--wait` cannot be combined with `--dry-run`.
- Use `policy set` when replacing the full policy or changing static sections.
Expand Down
98 changes: 87 additions & 11 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ glob = "0.3"
# Utilities
futures = "0.3"
bytes = "1"
hickory-proto = "0.26.1"
pin-project-lite = "0.2"
tokio-stream = "0.1"
protoc-bin-vendored = "3.2.0"
Expand Down
14 changes: 10 additions & 4 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,23 @@ socket inode.

CONNECT and absolute-form forward HTTP are explicit-proxy adapters over the same
egress pipeline. Each adapter normalizes its request into an egress intent, and
the shared authorization result carries the process evidence used by destination
validation and relay selection. During the compatibility migration, endpoint
state is hydrated at the adapters' existing policy query points; it is not yet
one atomic, generation-consistent authorization result. Destination validation
the shared authorization result carries the process evidence and endpoint
metadata used by destination validation and relay selection. Network action,
matched policy, endpoint configuration, and exact-host authorization are
evaluated as one atomic snapshot from one policy generation. Destination validation
returns an unopened connector so adapters retain their existing response and
upstream-dial timing. CONNECT prepares a generation-pinned relay context before
entering shared TLS-terminated or plaintext HTTP relays; non-HTTP traffic uses
the shared raw byte relay after the existing adapter gates. Forward HTTP retains
its guarded single-request relay while sharing authorization, request context,
policy-pinning, and destination boundaries.
Adapter-specific response and OCSF event shapes remain at the protocol boundary.
Policy authors may use `protocol: tcp` as an explicit spelling of the existing
L4 passthrough behavior. Explicit TCP endpoints require a valid DNS hostname;
hostless `allowed_ips` and literal-IP selectors remain available only to the
legacy forward-proxy path when `protocol` is omitted. The egress intent reserves
a transparent TCP adapter and a policy-DNS-pinned address, but DNS serving and
transparent TCP capture are not active yet.

Provider credential placeholders are resolved through the live provider state
for each HTTP request, after destination and L7 policy admission. A static
Expand Down
49 changes: 47 additions & 2 deletions crates/openshell-cli/src/policy_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,9 +327,14 @@ fn parse_add_endpoint_spec(spec: &str) -> Result<NetworkEndpoint> {
"--add-endpoint access segment must be one of read-only, read-write, or full; got '{access}' in '{spec}'"
));
}
if !protocol.is_empty() && !matches!(protocol, "rest" | "websocket" | "sql") {
if !protocol.is_empty() && !matches!(protocol, "tcp" | "rest" | "websocket" | "sql") {
Comment thread
johntmyers marked this conversation as resolved.
return Err(miette!(
"--add-endpoint protocol segment must be 'rest', 'websocket', or 'sql'; got '{protocol}' in '{spec}'"
"--add-endpoint protocol segment must be 'tcp', 'rest', 'websocket', or 'sql'; got '{protocol}' in '{spec}'"
));
}
if protocol == "tcp" && (!access.is_empty() || !enforcement.is_empty()) {
return Err(miette!(
"--add-endpoint protocol 'tcp' does not support access or enforcement in '{spec}'"
));
}
if !enforcement.is_empty() && !matches!(enforcement, "enforce" | "audit") {
Expand Down Expand Up @@ -547,6 +552,26 @@ mod tests {
assert_eq!(endpoint.enforcement, "enforce");
}

#[test]
fn parse_add_endpoint_accepts_explicit_tcp_protocol() {
let plan = build_policy_update_plan(
&["database.example.com:5432::tcp".to_string()],
&[],
&[],
&[],
&[],
&[],
None,
)
.expect("plan should build");

let PolicyMergeOp::AddRule { rule, .. } = &plan.preview_operations[0] else {
panic!("expected add-rule preview");
};
assert_eq!(rule.endpoints[0].protocol, "tcp");
assert!(rule.endpoints[0].access.is_empty());
}

#[test]
fn parse_add_endpoint_enables_websocket_credential_rewrite() {
let plan = build_policy_update_plan(
Expand Down Expand Up @@ -837,6 +862,26 @@ mod tests {
);
}

#[test]
fn parse_add_endpoint_rejects_l7_fields_with_tcp() {
let error = build_policy_update_plan(
&["database.example.com:5432::tcp:enforce".to_string()],
&[],
&[],
&[],
&[],
&[],
None,
)
.expect_err("TCP must reject L7 enforcement");

assert!(
error
.to_string()
.contains("does not support access or enforcement")
);
}

#[test]
fn parse_remove_endpoint_rejects_out_of_range_port() {
let error = build_policy_update_plan(
Expand Down
Loading
Loading