Skip to content

Commit e83e99d

Browse files
LukeMathWalkerluca-iachini
authored andcommitted
feat(run)!: require canonical capability seeds
Amp-Thread-ID: https://ampcode.com/threads/T-01a042e7-9125-736f-9bdc-ce404c78d707
1 parent 813774b commit e83e99d

27 files changed

Lines changed: 546 additions & 199 deletions

File tree

crates/firma-authority/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,10 @@ cargo run -p firma-authority -- --config firma.toml issue --agent-id agt_01j00
7575
```
7676

7777
The output file contains the signed token and matching claims. Pass it to
78-
`firma run --capability-file capability-demo-agent.toml`; `firma run` and the
79-
Sidecar verify it with the configured Authority public key before use.
78+
`firma run --capability-file capability-demo-agent.toml`; Run parses this
79+
canonical seed TOML and exports only its raw token to the wrapped process. A
80+
locally autostarted Sidecar also loads the file and verifies its token and
81+
matching claims with the configured Authority public key.
8082

8183
## Configuration
8284

crates/firma-core/src/capability_seed.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use crate::CapabilityClaims;
1414
/// [`CapabilityClaims`]; the `raw_token` is authoritative and re-verified at
1515
/// load time.
1616
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17+
#[serde(deny_unknown_fields)]
1718
pub struct CapabilitySeed {
1819
/// Raw PASETO v4.public token string.
1920
pub raw_token: String,
@@ -109,4 +110,16 @@ context_hash = "cafebabe"
109110
.unwrap()
110111
);
111112
}
113+
114+
#[test]
115+
fn rejects_unknown_fields() {
116+
let claims = sample_claims();
117+
let seed = CapabilitySeed::from_claims(&claims, "v4.public.tok".to_string());
118+
let body = format!("{}unknown = \"value\"\n", toml::to_string(&seed).unwrap());
119+
120+
let error = toml::from_str::<CapabilitySeed>(&body)
121+
.expect_err("unknown fields must fail the canonical seed boundary");
122+
123+
assert!(error.message().contains("unknown field"));
124+
}
112125
}

crates/firma-run/src/capability/mod.rs

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,18 @@ use crate::error::RunError;
99

1010
/// Read the operator-supplied capability token for a `firma run` session.
1111
///
12-
/// Only [`CapabilitySource::File`] carries a bring-your-own token; the token is
13-
/// injected into the agent process environment once at launch (see
14-
/// `build_execution_env`). Rotation is delegated to the agent via the
15-
/// `FIRMA_CAPABILITY_FILE` env var, so this is a one-shot read with no
16-
/// background refresh — the firma-minted per-session path uses
17-
/// [`refresh::CapabilityRefresher`] instead.
12+
/// Only [`CapabilitySource::File`] carries an operator-provided seed. Run parses
13+
/// the canonical [`firma_core::CapabilitySeed`] TOML and injects only its
14+
/// `raw_token` into the agent process environment (see `build_execution_env`).
15+
/// This is a one-shot Run read with no Run-managed background refresh. A local
16+
/// Sidecar independently reads and watches the same file; a pre-managed
17+
/// external Sidecar owns its seed configuration. The Firma-minted per-session
18+
/// path uses [`refresh::CapabilityRefresher`] instead.
1819
///
1920
/// # Errors
2021
///
21-
/// Returns [`RunError::Capability`] when the file is unreadable or empty.
22+
/// Returns [`RunError::Capability`] when the file is unreadable, is not a
23+
/// canonical capability seed, or contains an empty `raw_token`.
2224
pub fn read_capability_token(source: &CapabilitySource) -> Result<Option<String>, RunError> {
2325
match source {
2426
CapabilitySource::Disabled => Ok(None),
@@ -27,16 +29,21 @@ pub fn read_capability_token(source: &CapabilitySource) -> Result<Option<String>
2729
}
2830

2931
fn read_token(path: &Path) -> Result<String, RunError> {
30-
let value = std::fs::read_to_string(path)
32+
let body = std::fs::read_to_string(path)
3133
.map_err(|error| RunError::Capability(format!("{}: {error}", path.display())))?;
34+
let seed: firma_core::CapabilitySeed = toml::from_str(&body).map_err(|_| {
35+
RunError::Capability(format!(
36+
"capability seed '{}' is not canonical CapabilitySeed TOML",
37+
path.display()
38+
))
39+
})?;
3240

33-
let token = value.trim().to_string();
34-
if token.is_empty() {
41+
if seed.raw_token.trim().is_empty() {
3542
return Err(RunError::Capability(format!(
36-
"capability file {} is empty",
43+
"capability seed '{}' has an empty raw_token",
3744
path.display()
3845
)));
3946
}
4047

41-
Ok(token)
48+
Ok(seed.raw_token)
4249
}

crates/firma-run/src/routing.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ pub struct AutostartFlags {
284284
/// Sidecar credential config injected into `[sidecar.authority.credentials]`
285285
/// during sidecar config synthesis.
286286
pub authority_credentials: Option<SidecarCredentialsConfig>,
287-
/// Path of the per-session capability seed minted by `firma run`.
287+
/// Explicit or Firma-minted capability seed path for local Sidecar setup.
288288
pub capability_seed_path: Option<PathBuf>,
289289
/// When `true`, the autostarted sidecar is started in HTTP proxy
290290
/// interceptor mode rather than Unix socket mode.

crates/firma-run/src/runtime/mod.rs

Lines changed: 87 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ pub struct RunInput {
4040
pub backend: Option<crate::backend::BackendKind>,
4141
/// CLI value of `--sidecar` (`local` | `<tcp://...|unix:///...>` | unset).
4242
pub sidecar_cli: crate::sidecar::SidecarCli,
43-
/// Optional operator-supplied capability token file, injected into the
44-
/// agent environment at launch (bring-your-own token).
43+
/// Optional canonical signed capability seed TOML. Run exports only its raw
44+
/// token and the original file path to the agent environment at launch.
4545
pub capability_file: Option<PathBuf>,
4646
/// Override sandbox identity mode.
4747
pub identity_mode: Option<crate::config::SandboxIdentityMode>,
@@ -837,6 +837,21 @@ mod tests {
837837
use super::{RunIdentity, build_execution_env};
838838
use crate::backend::SandboxHandle;
839839

840+
fn canonical_seed_toml(raw_token: &str) -> String {
841+
format!(
842+
r#"raw_token = "{raw_token}"
843+
token_id = "ctok_01j0000000e008000000000001"
844+
agent_id = "agt_01j0000000e008000000000001"
845+
session_id = "sess_runtime"
846+
action_set = ["communication.external.send"]
847+
resource_scope = "*"
848+
issued_at = "2026-04-29T15:00:00Z"
849+
expiry = "2026-04-29T16:00:00Z"
850+
context_hash = "runtime-seed-canary"
851+
"#
852+
)
853+
}
854+
840855
/// Quotes a path the way the platform's VS Code shim does.
841856
fn quote_shim_path(path: &std::path::Path) -> String {
842857
let value = path.display().to_string();
@@ -941,64 +956,81 @@ mod tests {
941956
}
942957

943958
#[test]
944-
fn capability_file_is_exported_when_file_source_is_used() {
959+
fn capability_file_exports_exact_token_and_path_for_local_and_external_sidecars() {
945960
let tempdir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
946-
let token_path = tempdir.path().join("cap.token");
947-
fs::write(&token_path, "token").unwrap_or_else(|e| panic!("{e}"));
961+
let seed_path = tempdir.path().join("capability.toml");
962+
let raw_token = "v4.public.exact-runtime-token";
963+
let seed_document = canonical_seed_toml(raw_token);
964+
fs::write(&seed_path, &seed_document).unwrap_or_else(|e| panic!("{e}"));
965+
let endpoint = SidecarEndpoint::Tcp {
966+
addr: "127.0.0.1:8080".parse().unwrap_or_else(|e| panic!("{e}")),
967+
};
948968

949-
let profile = ResolvedProfile {
950-
id: "generic".to_string(),
951-
backend: crate::backend::BackendKind::Bwrap,
952-
sidecar_endpoint: SidecarEndpoint::Tcp {
953-
addr: "127.0.0.1:8080".parse().unwrap_or_else(|e| panic!("{e}")),
954-
},
955-
sidecar_selection: crate::sidecar::SidecarSelection::Local,
956-
env_passthrough: BTreeSet::default(),
957-
env_set: BTreeMap::default(),
958-
mounts: Vec::new(),
959-
seccomp_policy: None,
960-
network: NetworkPolicy {
961-
enforce_network_namespace: false,
962-
fail_closed: true,
963-
},
964-
identity_mode: SandboxIdentityMode::SandboxUser,
965-
capability: CapabilityLeaseConfig {
966-
source: CapabilitySource::File {
967-
path: token_path.clone(),
969+
for sidecar_selection in [
970+
crate::sidecar::SidecarSelection::Local,
971+
crate::sidecar::SidecarSelection::Remote(endpoint.clone()),
972+
] {
973+
let profile = ResolvedProfile {
974+
id: "generic".to_string(),
975+
backend: crate::backend::BackendKind::Bwrap,
976+
sidecar_endpoint: endpoint.clone(),
977+
sidecar_selection,
978+
env_passthrough: BTreeSet::default(),
979+
env_set: BTreeMap::default(),
980+
mounts: Vec::new(),
981+
seccomp_policy: None,
982+
network: NetworkPolicy {
983+
enforce_network_namespace: false,
984+
fail_closed: true,
968985
},
969-
public_key_path: None,
970-
refresh_ratio: 0.60,
971-
grace: Duration::from_secs(30),
972-
requested_actions: CapabilityLeaseConfig::default_requested_actions(),
973-
},
974-
sidecar_local_exec: None,
975-
secret_gateway_addr: None,
976-
secret_providers: BTreeMap::new(),
977-
executable_policies: BTreeMap::new(),
978-
use_http_proxy_sidecar: false,
979-
allow_non_structural: false,
980-
ca_trust_mode: crate::config::CaTrustMode::Sole,
981-
};
986+
identity_mode: SandboxIdentityMode::SandboxUser,
987+
capability: CapabilityLeaseConfig {
988+
source: CapabilitySource::File {
989+
path: seed_path.clone(),
990+
},
991+
public_key_path: None,
992+
refresh_ratio: 0.60,
993+
grace: Duration::from_secs(30),
994+
requested_actions: CapabilityLeaseConfig::default_requested_actions(),
995+
},
996+
sidecar_local_exec: None,
997+
secret_gateway_addr: None,
998+
secret_providers: BTreeMap::new(),
999+
executable_policies: BTreeMap::new(),
1000+
use_http_proxy_sidecar: false,
1001+
allow_non_structural: false,
1002+
ca_trust_mode: crate::config::CaTrustMode::Sole,
1003+
};
9821004

983-
let identity = RunIdentity::new(crate::identity::test_agent_id(), "generic");
984-
let capability_token = crate::capability::read_capability_token(&profile.capability.source)
985-
.unwrap_or_else(|e| panic!("{e}"));
1005+
let identity = RunIdentity::new(crate::identity::test_agent_id(), "generic");
1006+
let capability_token =
1007+
crate::capability::read_capability_token(&profile.capability.source)
1008+
.unwrap_or_else(|e| panic!("{e}"));
9861009

987-
let env = build_execution_env(
988-
&profile,
989-
&identity,
990-
capability_token.as_deref(),
991-
&profile.sidecar_endpoint,
992-
&BTreeMap::default(),
993-
);
994-
assert_eq!(
995-
env.get("FIRMA_CAPABILITY_FILE"),
996-
Some(&token_path.display().to_string())
997-
);
998-
assert_eq!(
999-
env.get("FIRMA_CAPABILITY_TOKEN"),
1000-
Some(&"token".to_string())
1001-
);
1010+
let env = build_execution_env(
1011+
&profile,
1012+
&identity,
1013+
capability_token.as_deref(),
1014+
&profile.sidecar_endpoint,
1015+
&BTreeMap::default(),
1016+
);
1017+
assert_eq!(
1018+
env.get("FIRMA_CAPABILITY_FILE"),
1019+
Some(&seed_path.display().to_string())
1020+
);
1021+
assert_eq!(
1022+
env.get("FIRMA_CAPABILITY_TOKEN").map(String::as_str),
1023+
Some(raw_token)
1024+
);
1025+
assert_ne!(
1026+
env.get("FIRMA_CAPABILITY_TOKEN").map(String::as_str),
1027+
Some(seed_document.as_str())
1028+
);
1029+
assert!(
1030+
!env.get("FIRMA_CAPABILITY_TOKEN")
1031+
.is_some_and(|value| value.contains("runtime-seed-canary"))
1032+
);
1033+
}
10021034
}
10031035

10041036
#[test]

crates/firma-run/src/sidecar/config.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -327,9 +327,9 @@ pub struct SynthesizeRequest<'a> {
327327
/// Sidecar credentials to inject into `[sidecar.authority.credentials]`.
328328
/// `None` leaves any existing template value untouched.
329329
pub authority_credentials: Option<&'a SidecarCredentialsConfig>,
330-
/// Path of the per-session capability seed minted by `firma run`, appended
331-
/// to `[sidecar.capability_seed].paths`. `None` when no seed was minted
332-
/// (e.g. `--capability-file` was passed).
330+
/// Capability seed path appended to `[sidecar.capability_seed].paths`.
331+
/// This is either an explicit file source or a per-session seed minted by
332+
/// `firma run`; `None` means no seed is available for local Sidecar setup.
333333
pub capability_seed_path: Option<&'a Path>,
334334
/// Audit log path used as the default `file` sink when the template does
335335
/// not configure an audit sink. Set to the shared state/runtime dir's

0 commit comments

Comments
 (0)