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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ as a definition does.
| `setup.rs` | `sudo porta setup`: a copy in a directory only root can write (`/usr/local/bin`, else `/usr/libexec/porta` linked from it) and the AppArmor profile that gives that copy user namespaces |
| `snapshot.rs` | `--snapshot`: the writable mounts copied before a run to `~/.porta/snapshots`, what the run changed, and `porta rollback` |
| `recipes.rs` + `recipes/*.toml` | `porta init claude` / `codex`: a `porta.toml` measured to what each agent needs |
| `http_proxy.rs`, `proxy_audit.rs` | the loopback CONNECT proxy and its decision trail |
| `http_proxy.rs`, `proxy_egress.rs`, `proxy_audit.rs` | the loopback CONNECT proxy, its run credential and address guard, and its decision trail |
| `credential_broker.rs` | credentials as placeholders: the run's CA, TLS terminated for bound hosts only, the real value put on where it is bound and refused elsewhere |
| `http_client.rs`, `host_process.rs`, `wasm_inspect.rs` | checked host services: one HTTP request, process helpers, module inspection |
| `agent_runtime.rs` + `agent_runtime/` | the broker: `loading` (config, pins, team), `guest`, `model`, `verification`, `tools`, `inspect`, `ffi` |
| `agent_journal.rs` | durable broker records, exclusive access, replay validation |
Expand Down Expand Up @@ -136,6 +137,9 @@ accessors use `value.*`; serialization and typed key lookups use `json.*`.
- Proxy mode permits only the loopback proxy endpoint, without UDP, Unix
sockets or any other egress channel — including a ring that would open a
socket without `socket(2)`.
- A credential's value never enters the child's environment, never reaches a
log, and is put on only for a host and path it is bound to; a host no
credential names is tunnelled, never intercepted.
- MCP stdio uses newline-delimited JSON; diagnostics belong on stderr.
- MCP result adaptation must preserve multi-field payloads and error status; only legacy single-key Result envelopes unwrap.
- Agent and tool instances inherit no host environment or implicit directories.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@ Linux ([overhead](docs/benchmarks/overhead.md)).
([agent runtime](docs/agent-runtime.md)).
- **One API and a log.** `--proxy-allow api.example.com --proxy-audit
egress.jsonl` lets through only that host and records every decision.
- **Keys the command cannot read.** `--credential ANTHROPIC_API_KEY=api.anthropic.com`
hands the command a placeholder; porta's proxy puts the real key on requests
to that host alone, and refuses the placeholder anywhere else it looks
([enforcement](docs/enforcement.md#credentials-as-placeholders)).

## Docs

Expand Down
6 changes: 6 additions & 0 deletions almide.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,9 @@ libc = "0.2"
regex = "1"

toml = "0.8"
# The credential broker (#37): TLS terminated for bound hosts with a CA made
# per run, and re-opened to the real server. ring, the provider reqwest's
# rustls already builds, so no second crypto library and no OpenSSL.
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
rcgen = { version = "0.13", default-features = false, features = ["crypto", "ring", "pem"] }
webpki-roots = "1"
6 changes: 6 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ line, never dropped.
| `--proxy-allow <hosts>` | Route egress through porta's CONNECT proxy and allow only these hosts |
| `--proxy-deny <hosts>` | Same, denying these hosts |
| `--proxy-audit <path>` | Append every proxy decision to a JSONL file |
| `--credential <NAME=HOST[,HOST]>` | Hand host variable NAME in as a placeholder; the proxy puts the real value on requests to `host[:443][/path]` only (repeatable) |
| `--read-policy <open\|strict>` | `strict` confines reads to your mounts and the system directories (default `open`) |
| `--allow-root` | Run as root anyway. Refused by default: for root, the file permissions this policy leans on separate nothing |
| `--env-pass <NAME,...>` | Copy these host variables into the command. The child starts from an empty environment plus `PATH`, `HOME`, `USER`, `SHELL`, `TERM` and the locale; nothing else of your shell crosses unless `-e` or this names it |
Expand Down Expand Up @@ -124,6 +125,11 @@ network = ["*:443"] # Restrict to these ports (empty = all open)
# allow = ["api.example.com"] # egress only through the CONNECT proxy, these hosts
# audit = "egress.jsonl"

# [[credentials]] # a placeholder inside, the real value put on by the proxy
# name = "ANTHROPIC_API_KEY"
# from = { env = "ANTHROPIC_API_KEY" }
# hosts = ["api.anthropic.com/v1"]

[env]
NODE_ENV = "production"

Expand Down
46 changes: 44 additions & 2 deletions docs/enforcement.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,50 @@ allow-list. An allowed name that resolves to loopback, a link-local or cloud
metadata address, or a multicast group is refused: a hostname on the list is a
promise about a public service, not a route to this machine. Private ranges
stay reachable. Every decision is written and synced to the audit file before
the connection proceeds. Deny lists are weaker than explicit allow lists, and
this is not a credential broker.
the connection proceeds. Deny lists are weaker than explicit allow lists.

## Credentials as placeholders

```bash
porta run claude -v . --credential ANTHROPIC_API_KEY=api.anthropic.com
```

Or in `porta.toml`:

```toml
[[credentials]]
name = "ANTHROPIC_API_KEY" # the variable the command sees
from = { env = "ANTHROPIC_API_KEY" } # where porta reads the real value (default: the name)
hosts = ["api.anthropic.com/v1"] # host[:443][/path], *.wildcard allowed
```

The command's `ANTHROPIC_API_KEY` is `porta-cred-ANTHROPIC_API_KEY-<random>`,
minted for this run. The real value stays in porta. A credential turns the
proxy on (with no `--proxy-allow`, every host is reachable through it; with one,
the bound hosts are added to it), and for a bound host porta terminates the
command's TLS with a certificate from a CA made for this run, replaces the
placeholder wherever it appears in the request line, query or headers
(`Authorization: Bearer …`, `x-api-key: …` alike), and sends the request on
over its own TLS connection, which verifies the real server against the
webpki roots. The same placeholder in a request to a bound host on a path its
credential is not bound to is refused with 403 before anything is sent. Every
substitution and refusal goes to stderr and the audit file with the
credential's name, never its value.

The command is pointed at the run's CA with `SSL_CERT_FILE`,
`REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE` and `GIT_SSL_CAINFO` (a bundle of the
system's roots plus the CA, since those replace the trust store) and
`NODE_EXTRA_CA_CERTS` (the CA alone, since Node adds it). The CA is valid for
a week, lives in memory, and its certificate and the bundle are removed when
the run ends. A client that ignores all of these (Go on macOS, which asks the
Keychain) will not trust the intercepted connection, and fails closed.

Only bound hosts are intercepted. Every other CONNECT is tunnelled as before,
end to end and unread; a placeholder sent there is only a random string. Each
intercepted connection carries one request (porta asks the server for
`Connection: close`), and speaks HTTP/1.1 only; request bodies are passed on,
not inspected. Values come from porta's environment for now; a file, the
Keychain or a command as the source are left for later.

## WASM sandbox

Expand Down
5 changes: 5 additions & 0 deletions docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ true.
| Proxy mode: the loopback proxy is the only egress | Landlock TCP port rule + seccomp socket-family filter; Seatbelt profile | integration "proxy mode is the only egress" |
| The proxy serves only this run's client, and never tunnels to loopback/metadata addresses | per-run credential; resolved-address guard | integration "CONNECT needs the run credential" |
| Syscalls that reach around a file policy are refused (Linux, every mode) | seccomp baseline: `ptrace`, `process_vm_*`, mounts, namespaces, `io_uring`, `execveat(AT_EMPTY_PATH)`, … | `escapes.py` "ptrace", "fileless exec", "io_uring", "new user namespace" |
| A credential given with `--credential` / `[[credentials]]` is never in the child's environment, and its value goes only on requests to the hosts and paths it is bound to; a placeholder on any other path of an intercepted host is refused and recorded | per-run placeholder; the proxy terminates TLS for bound hosts only, with a per-run CA, and verifies the real server | integration "a credential is a placeholder inside" |
| The child inherits no host environment beyond `PATH`, `HOME`, locale, terminal | `env_clear` then a named allow-list | integration "the host environment stays outside" |
| A policy the platform cannot express refuses the run | `prepare` returns an error; `run` exits 125 | integration "unexpressible rule refused" |

Expand All @@ -59,6 +60,10 @@ true.
These are out of scope by design, not undiscovered. A report that porta does
not stop one of them is accurate and not a vulnerability.

- **Use of a credential where it is bound.** A credential handed in as a
placeholder cannot be read or sent anywhere else, but the command can still
make any request it likes to the host the credential is bound to, and porta
will put the key on it. Bind to the narrowest path that does the job.
- **Kernel exploits.** porta's boundary is the kernel's. A privilege escalation
through a kernel bug defeats it — and defeats a container on the same kernel.
Those belong upstream.
Expand Down
225 changes: 225 additions & 0 deletions native/credential_broker.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
//! Credentials handed in as placeholders, and put on in the proxy (#37).
//!
//! A command confined by porta that needs an API key used to get the key
//! itself, in its environment, where every process it starts can read it and
//! send it anywhere its network allows. Here it gets a placeholder instead:
//! `porta-cred-NAME-<random>`, worth nothing outside this run. The real value
//! stays in porta. When the command's HTTPS request to a host the credential
//! is bound to comes through the proxy, porta terminates that TLS connection
//! with a certificate from a CA made for this run, puts the real value where
//! the placeholder is, and sends the request on over a TLS connection of its
//! own that verifies the real server. The same placeholder on its way to
//! anywhere else among the intercepted hosts is refused, and recorded.
//!
//! Only bound hosts are intercepted. Every other CONNECT is tunnelled exactly
//! as before, encrypted end to end and not read: a placeholder sent there is
//! only a random string.
//!
//! This file holds the credentials and the rule for where each may go; `tls`
//! makes the run's CA and the certificates it signs; `exchange` carries one
//! intercepted request across.

use crate::proxy_egress::mint_token;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

mod exchange;
mod tls;

/// Where one credential may go: a host pattern (`*.example.com` allowed), the
/// port, and a path prefix ("" for any path).
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Binding {
pub(crate) host: String,
pub(crate) port: u16,
pub(crate) path: String,
}

/// One credential: the name the command sees it under, the real value, the
/// placeholder the command is given instead, and where it may be put on.
pub(crate) struct Credential {
pub(crate) name: String,
value: String,
pub(crate) placeholder: String,
pub(crate) bindings: Vec<Binding>,
}

/// The host and port a CONNECT asked for.
#[derive(Clone, Copy)]
pub(crate) struct Target<'a> {
pub(crate) host: &'a str,
pub(crate) port: u16,
}

/// `host[:port][/path]` as `--credential` and `[[credentials]]` write it. The
/// proxy tunnels port 443 alone, so a binding to another port could never be
/// used, and is refused here rather than silently doing nothing.
pub(crate) fn parse_binding(text: &str) -> Result<Binding, String> {
let text = text.trim();
let (authority, path) = match text.find('/') {
Some(slash) => (&text[..slash], &text[slash..]),
None => (text, ""),
};
let (host, port) = match authority.rsplit_once(':') {
Some((host, port)) => (host, port.parse::<u16>().map_err(|_| format!("credential binding {text}: the port is not a number"))?),
None => (authority, 443),
};
if host.is_empty() {
return Err(format!("credential binding {text}: no host"));
}
if port != 443 {
return Err(format!("credential binding {text}: the proxy carries HTTPS on port 443 only"));
}
Ok(Binding { host: host.to_ascii_lowercase(), port, path: path.trim_end_matches('/').to_string() })
}

impl Binding {
fn reaches(&self, at: Target) -> bool {
crate::http_proxy::host_matches(at.host, &self.host) && self.port == at.port
}

fn covers(&self, at: Target, request_target: &str) -> bool {
self.reaches(at) && path_under(request_target, &self.path)
}
}

/// Whether a request target (query and all) is `prefix` or below it: `/v1`
/// covers `/v1`, `/v1/messages` and `/v1?x`, not `/v10`.
fn path_under(request_target: &str, prefix: &str) -> bool {
if prefix.is_empty() {
return true;
}
let path = request_target.split(['?', '#']).next().unwrap_or("");
path == prefix || path.strip_prefix(prefix).is_some_and(|rest| rest.starts_with('/'))
}

/// What the request looks like once the placeholders in it have been dealt
/// with: every bound one replaced, or the first one that may not go here.
#[derive(Debug, PartialEq)]
pub(crate) enum Rewrite {
/// The head to send on, and the names of the credentials put on.
Send(String, Vec<String>),
/// A placeholder for this credential is in a request it is not bound to.
Refuse(String),
}

/// Replaces each credential's placeholder in the request head (request line,
/// query and headers alike) with its value, where the credential is bound to
/// this host, port and path; refuses the request when a placeholder is there
/// and it is not.
pub(crate) fn rewrite_head(credentials: &[Credential], head: &str, at: Target, request_target: &str) -> Rewrite {
let mut out = head.to_string();
let mut used = Vec::new();
for credential in credentials.iter().filter(|c| head.contains(&c.placeholder)) {
if !credential.bindings.iter().any(|binding| binding.covers(at, request_target)) {
return Rewrite::Refuse(credential.name.clone());
}
out = out.replace(&credential.placeholder, &credential.value);
used.push(credential.name.clone());
}
Rewrite::Send(out, used)
}

/// The run's credentials, its CA, and the leaf certificates signed so far.
pub(crate) struct Broker {
pub(crate) credentials: Vec<Credential>,
ca: tls::Authority,
leaves: Mutex<HashMap<String, Arc<rustls::ServerConfig>>>,
upstream: Arc<rustls::ClientConfig>,
/// The directory holding the CA certificate and the trust bundle, removed
/// when the proxy stops.
pub(crate) dir: String,
ca_path: String,
bundle_path: Option<String>,
}

/// One credential as the caller hands it over: the value read from where the
/// configuration said, and the bindings as written.
#[derive(serde::Deserialize)]
pub(crate) struct Requested {
name: String,
value: String,
hosts: Vec<String>,
}

/// The broker a `[{"name","value","hosts"}]` list asks for, or None for an
/// empty one: a run with no credentials tunnels everything, as before.
pub(crate) fn broker_from_json(credentials_json: &str) -> Result<Option<Arc<Broker>>, String> {
let requested: Vec<Requested> = serde_json::from_str(credentials_json).map_err(|e| format!("credentials: {e}"))?;
if requested.is_empty() {
return Ok(None);
}
Broker::new(requested).map(|broker| Some(Arc::new(broker)))
}

impl Broker {
/// A broker for these credentials: placeholders minted, the run's CA made
/// and written out with a trust bundle the command is pointed at.
fn new(requested: Vec<Requested>) -> Result<Broker, String> {
let credentials = merged(requested)?;
let ca = tls::Authority::new()?;
let dir = format!("/tmp/porta-credentials-{}", mint_token());
std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {dir}: {e}"))?;
let (ca_path, bundle_path) = ca.write_trust(&dir)?;
Ok(Broker { credentials, ca, leaves: Mutex::new(HashMap::new()), upstream: tls::upstream_config()?, dir, ca_path, bundle_path })
}

/// Whether any credential is bound to this host and port, so its
/// connections are opened rather than tunnelled.
pub(crate) fn intercepts(&self, at: Target) -> bool {
self.credentials.iter().any(|c| c.bindings.iter().any(|b| b.reaches(at)))
}

/// The hosts to add to an allow-list: a credential bound to a host the
/// list does not reach would be handed in for nothing.
pub(crate) fn hosts(&self) -> Vec<String> {
self.credentials.iter().flat_map(|c| c.bindings.iter().map(|b| b.host.clone())).collect()
}

/// What the command's environment gains: each credential's placeholder
/// under its name, and the variables that make its TLS clients trust the
/// run's CA.
pub(crate) fn child_env(&self) -> Vec<(String, String)> {
let mut env: Vec<(String, String)> = self.credentials.iter().map(|c| (c.name.clone(), c.placeholder.clone())).collect();
// Node adds this file to its own roots; it is the one client here that
// takes an addition rather than a replacement.
env.push(("NODE_EXTRA_CA_CERTS".into(), self.ca_path.clone()));
if let Some(bundle) = &self.bundle_path {
for name in ["SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO"] {
env.push((name.into(), bundle.clone()));
}
}
env
}

fn leaf_for(&self, host: &str) -> Result<Arc<rustls::ServerConfig>, String> {
let mut leaves = crate::locking::locked(&self.leaves);
if let Some(config) = leaves.get(host) {
return Ok(config.clone());
}
let config = Arc::new(self.ca.server_config_for(host)?);
leaves.insert(host.to_string(), config.clone());
Ok(config)
}
}

/// Credentials by name: two bindings given for one name are one credential
/// with one placeholder. A name with no value is refused rather than handed
/// in as an empty key.
fn merged(requested: Vec<Requested>) -> Result<Vec<Credential>, String> {
let mut out: Vec<Credential> = Vec::new();
for r in requested {
if r.value.is_empty() {
return Err(format!("credential {}: no value to hand over (is it set in porta's environment?)", r.name));
}
let bindings = r.hosts.iter().map(|h| parse_binding(h)).collect::<Result<Vec<_>, _>>()?;
match out.iter_mut().find(|c| c.name == r.name) {
Some(existing) => existing.bindings.extend(bindings),
None => out.push(Credential { placeholder: format!("porta-cred-{}-{}", r.name, mint_token()), name: r.name, value: r.value, bindings }),
}
}
Ok(out)
}

#[cfg(test)]
mod tests;
Loading
Loading