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
52 changes: 52 additions & 0 deletions Cargo.lock

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

11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,28 +28,39 @@ required-features = ["e2e"]
anyhow = "1.0"
async-stream = "0.3"
axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] }
base64 = "0.22"
blake3 = "1.5"
bytes = "1.5"
candle-core = { version = "=0.10.2", optional = true, default-features = false }
clap = { version = "4.5", features = ["derive"] }
dialoguer = { version = "0.12", features = ["fuzzy-select"] }
directories = "5.0"
futures-util = "0.3"
heck = "0.5"
hf-hub = { version = "=0.4.3", optional = true, default-features = false }
http-body-util = "0.1"
hyper = { version = "1.5", features = ["http1", "http2", "server", "client"] }
hyper-util = { version = "0.1", features = ["tokio", "server", "client", "client-legacy", "http1", "http2"] }
ignore = "0.4"
indexmap = { version = "2.14", optional = true }
jiff = "0.2"
mistralrs-core = { version = "=0.8.1", optional = true, default-features = false }
nix = { version = "0.29", features = ["user"] }
rand = "0.8"
rcgen = { version = "0.13", default-features = false, features = ["pem", "ring"] }
regex = "1.10"
reqwest = { version = "0.12", optional = true, default-features = false, features = ["json", "rustls-tls"] }
rig-core = { version = "0.36", default-features = false, features = ["reqwest", "rustls"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
rustls-native-certs = "0.8"
rustls-pemfile = "2.2"
schemars = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tempfile = "3.10"
thiserror = "1.0"
time = "0.3"
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
toml = "0.8"
toml_edit = "0.22"
tracing = "0.1"
Expand Down
1 change: 1 addition & 0 deletions doc/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- [MCP Servers](concepts/mcp-servers.md)
- [MCP Trust Model](concepts/mcp-trust-model.md)
- [Workspace](concepts/workspace.md)
- [Network MITM](concepts/network-mitm.md)
- [Providers, Models, and Agents](concepts/llm-providers.md)
- [In-process LLMs](concepts/in-process-llm.md)

Expand Down
127 changes: 127 additions & 0 deletions doc/concepts/network-mitm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Network MITM

> Opt-in HTTPS interception. Off unless you explicitly enable it. The session CA is generated
> fresh per session, lives only under the session directory, and is never installed as a host
> trust anchor.

## What MITM does

The 0059/0060 network interceptor sees HTTPS as opaque bytes. It can record the destination IP
and the TLS SNI, but URL, method, status, and body all stay inside the encrypted tunnel.
Policy stops at the connection layer: a single allow for `github.com:443` lets the container
hit every URL on the host.

MITM mode flips that. With MITM on, the interceptor:

1. Generates a per-session ECDSA P-256 CA at startup, writes the public cert to
`<session_dir>/tls/ca.crt` and the private key to `<session_dir>/tls/ca.key` (mode `0600`).
2. Installs the CA into the session container's trust stores (system bundle + standalone PEM
+ language env vars). The host trust anchors are untouched.
3. Terminates incoming TLS on each HTTPS-bearing port, minting a leaf certificate keyed on the
client's SNI and signed by the session CA.
4. Opens its own TLS connection upstream, validated against the host's native trust anchors.
5. Inspects each HTTP request, applies URL-aware policy, optionally captures bodies, then
forwards or rejects.
6. Removes the private key during teardown. The public cert stays so audit-log readers can
verify recorded handshakes after the session ends.

ALPN advertises both `h2` and `http/1.1` in each direction, so HTTP/1.1 and HTTP/2 clients
both work transparently.

## Enabling MITM

MITM composes with `mode = "audit"` or `mode = "filter"`. It is a separate toggle, not a
fourth value for `mode`. Enable it under `[network.mitm]`:

```toml
[network]
mode = "filter"
default = "deny"
allow = ["github.com:443"]

[network.mitm]
enable = true
```

See [Reference -> Config](../reference/config.md) for the full schema, including
`capture-bodies`, `max-body-bytes`, and `ports`.

## URL-aware policy

Entries in `[network].allow` and `[network].deny` may carry an optional `path` glob using the
inline-table form. Entries without `path` keep their 0060 host/port semantics. Entries with
`path` are skipped at TCP-open time and only fire after MITM has parsed the HTTP request
line:

```toml
[network]
mode = "filter"
default = "deny"
allow = [
"api.github.com:443",
{ host = "api.github.com", port = 443, path = "/repos/*" },
]
deny = [
{ host = "api.github.com", port = 443, path = "/admin/*" },
]
```

`deny` wins over `allow`, and unmatched requests fall through to `default`. Path-bearing
entries require `enable = true`; the validator rejects them otherwise.

## Audit log shape

Every HTTPS request handled by MITM produces one record in `<session_dir>/logs/network.jsonl`
(non-MITM connections produce the 0060 shape unchanged):

```json
{"ts": 1700000000.0, "uid": "C...", "id.orig_h": "10.0.2.100", "id.orig_p": 50123,
"id.resp_h": "140.82.121.5", "id.resp_p": 443, "proto": "tcp", "service": "ssl-mitm",
"duration": 0.082, "orig_bytes": 0, "resp_bytes": 0, "conn_state": "SF",
"local_orig": true, "local_resp": false, "missed_bytes": 0,
"server_name": "api.github.com",
"method": "GET", "url": "https://api.github.com/repos/foo/bar", "status": 200,
"outrig.session_id": "...", "outrig.container": "...",
"outrig.host": "api.github.com", "outrig.action": "allow", "outrig.rule": "allow[1]"}
```

The Zeek `uid` matches the TCP-level audit record's `uid` for the same flow, so consumers can
group request records with their underlying TCP connection. `service` is `ssl-mitm` (vs.
`ssl` for opaque-pass-through TLS) to make the two record types easy to filter.

When `capture-bodies = true`, the record also carries `outrig.request_body_b64` and/or
`outrig.response_body_b64` (base64-encoded, capped at `max-body-bytes` each) plus an
`outrig.body_truncated` flag (`"request"`, `"response"`, or `"both"`) when the cap fired.
Bodies past the cap still pass on the wire; only the captured copy is truncated.

## Limitations

- **HTTPS to IP literals (no SNI)** passes through opaquely, exactly as it would with MITM
off. The interceptor cannot honestly mint a name-bearing leaf for an IP destination, so it
declines to terminate TLS in that case. URL/method/status are not captured for those
connections.
- **HTTP/2 streams are proxied, not tunneled.** Hyper handles header decoding, flow control,
and stream multiplexing under the hood, so end-to-end HTTP/2 features (server push, custom
trailers beyond what hyper exposes) may not round-trip.
- **Client certificate authentication is not supported.** The interceptor accepts client TLS
with `with_no_client_auth`, so any upstream that requires a client cert from the agent
side will fail with MITM enabled.
- **CA validity is 30 days from session start.** Sessions running past that window will see
TLS handshake failures inside the container.

## Cleanup

When the session ends, `NetworkInterceptor::shutdown` removes
`<session_dir>/tls/ca.key`. The public certificate stays at
`<session_dir>/tls/ca.crt` for post-mortem analysis. The container is destroyed at the same
time, so the trust-store installation goes with it.

If you need to inspect the CA after the session, `openssl x509 -in <session_dir>/tls/ca.crt
-text -noout` shows the subject, validity, and basic constraints.

## See also

- [Concepts -> Workspace](workspace.md) -- audit/filter modes and the broader network model.
- [Reference -> Config](../reference/config.md) -- the `[network]` and `[network.mitm]` schema.
- [Usage -> Sessions](../usage/sessions.md) -- session directory layout and `network.jsonl`
fields.
5 changes: 3 additions & 2 deletions doc/concepts/workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,9 @@ mode = "audit"
Audit mode writes one Zeek `conn.log`-style JSON object per connection to
`<session_dir>/logs/network.jsonl`. Audit mode is allow-and-log only: every connection is still
allowed, but records include the best known host, destination IP and port, transport, service,
byte counts, and duration. HTTPS remains opaque except for TLS SNI. URL, method, status, and
body inspection are deferred.
byte counts, and duration. By default HTTPS remains opaque except for TLS SNI; enable the
[MITM mode](network-mitm.md) to terminate TLS, record method/URL/status, and apply URL-aware
policy.

Filter mode uses the same interceptor and audit log, then applies global host/port policy
before opening upstream TCP connections:
Expand Down
52 changes: 52 additions & 0 deletions doc/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,58 @@ override this setting for one fresh session. `--network audit` and `--network fi
rejected with `outrig mcp --attach` because borrowed containers are not retrofitted with a new
interceptor.

### `[network.mitm]`

HTTPS MITM is an opt-in extension that composes with `mode = "audit"` or `mode = "filter"`.
When enabled, the interceptor generates a per-session CA, installs its public certificate into
the session container's trust store, terminates incoming TLS on the configured ports, and
re-encrypts upstream. Audit records gain `method`, `url`, and `status`. See
[Concepts -> Network MITM](../concepts/network-mitm.md) for the full security model.

```toml
[network.mitm]
enable = true # default false; master switch for HTTPS MITM
ports = [443] # default [443]; ports treated as HTTPS-bearing
capture-bodies = false # default false; opt-in body capture
max-body-bytes = 65536 # default 65536; per-body cap when capture is on
```

| Key | Type | Required | Default | Description |
|------------------|----------|----------|---------|------------------------------------------|
| `enable` | bool | no | `false` | Toggle MITM on/off. |
| `ports` | int list | no | `[443]` | Ports treated as HTTPS-bearing. |
| `capture-bodies` | bool | no | `false` | Include base64 bodies in audit records. |
| `max-body-bytes` | integer | no | `65536` | Per-body cap (bytes) when capture is on. |

Repo configs may set `[network.mitm].enable` (so a repo can opt out of MITM even when the
global config opts in), but cannot set `ports`, `capture-bodies`, or `max-body-bytes`. Those
are global-only, like the other policy keys.

When MITM is enabled, `allow` and `deny` entries may carry an optional `path` glob (inline-table
form only). URL rules ride the same lists as host/port rules; entries without `path` keep the
host-only behavior from `mode = "filter"`. Entries with `path` are skipped at TCP-open time and
only fire after MITM has parsed the request line:

```toml
[network]
mode = "filter"
default = "deny"
allow = [
"github.com:443",
{ host = "api.github.com", port = 443, path = "/repos/*" },
]
deny = [
"*:22",
{ host = "api.github.com", port = 443, path = "/admin/*" },
]

[network.mitm]
enable = true
```

The path glob uses the same `*` semantics as host globs and is case-sensitive. A `path` entry
without `[network.mitm].enable = true` is rejected at config validation.

## `[providers.<name>]`

A provider tells outrig how to reach a model -- either a remote HTTPS endpoint that speaks
Expand Down
7 changes: 7 additions & 0 deletions doc/usage/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,13 @@ the sniffed application (`http`, `ssl`, `ssh`, or `-`), `duration` is seconds, a
metadata is namespaced under `outrig.*`, including `outrig.action`, `outrig.rule`, and
`outrig.host`.

When MITM is enabled, each HTTPS request produces an additional record with
`service = "ssl-mitm"` carrying `method`, `url`, and `status` (and, with `capture-bodies` on,
`outrig.request_body_b64`, `outrig.response_body_b64`, and `outrig.body_truncated`). The `uid`
on the MITM record matches the TCP-level record for the same connection, so grouping with
`jq 'group_by(.uid)'` recovers the request flow. See
[Concepts -> Network MITM](../concepts/network-mitm.md).

## `outrig discard`

Delete a session's entire on-disk record (including logs):
Expand Down
Loading
Loading