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

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

7 changes: 7 additions & 0 deletions crates/nono-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ nix = { workspace = true, features = ["process", "signal", "fs", "user", "term"]
dns-lookup = "2"
keyring = { version = "3", features = ["apple-native"], optional = true, default-features = false }
security-framework = "3"
# Direct FFI usage in `tool_sandbox::broker_store` for the keychain ACL
# (SecAccessCreate / kSecAttrAccess). Security.framework is already linked
# via security-framework; these are sibling crates from the same family
# exposing types and constants security-framework does not re-export.
security-framework-sys = "2"
core-foundation = "0.10"
core-foundation-sys = "0.8"
x509-parser = "0.18"

[build-dependencies]
Expand Down
195 changes: 194 additions & 1 deletion crates/nono-cli/src/command_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,18 @@ pub enum LocalSocketMode {
Connect,
}

/// Output format for an [`InterceptActionConfig::Capture`] action.
///
/// Absent on the action means opaque stdout (full-buffer nonce reissue).
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CaptureFormat {
/// Parse stdout as a JSON object and substitute a broker nonce for the
/// string value at each `secret_paths` entry, leaving all other fields
/// untouched.
Json,
}

/// Action to take when an [`InterceptRuleConfig`] matches.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
Expand All @@ -381,7 +393,32 @@ pub enum InterceptActionConfig {
/// Fork the child, capture its stdout/stderr, and return the buffered output
/// in the shim response. Primary use: credential-bearing output scanned by
/// the token broker before reaching the agent.
Capture,
///
/// By default (no `format`) the whole captured stdout is scanned and any
/// broker nonce or broker-held secret is reissued before the buffer reaches
/// the agent (`TokenBroker::scan_and_reissue`).
///
/// With `format: "json"`, stdout is parsed as a JSON object and only the
/// string values at the dotted `secret_paths` are replaced with freshly
/// minted nonces; every other field passes through unchanged. Use this for
/// a single command returning a structured envelope with one or more
/// credentials at known field paths (e.g. `security find-generic-password`
/// emitting OAuth tokens under `claudeAiOauth.accessToken`). The rewrite is
/// fail-closed: malformed JSON, or a path resolving to a non-string value,
/// returns a sandbox-safe error instead of the raw stdout.
Capture {
/// Output format. Absent means opaque stdout (the original capture
/// semantics: full-buffer nonce reissue).
#[serde(default, skip_serializing_if = "Option::is_none")]
format: Option<CaptureFormat>,
/// Dotted JSON field paths whose string values are each minted as a
/// separate nonce. Required (non-empty) when `format` is `"json"`;
/// ignored otherwise. Each path must resolve to a JSON string; a
/// missing path is silently skipped. Object keys containing literal
/// dots are not supported (`a.b.c` always traverses three keys).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
secret_paths: Vec<String>,
},
/// Capture stdout, store it as a named tool-sandbox ambient credential, and return
/// a broker nonce instead of the real value.
CaptureCredential {
Expand Down Expand Up @@ -1276,6 +1313,32 @@ fn validate_intercept_rules(
report,
);
}
if let InterceptActionConfig::Capture {
format: Some(CaptureFormat::Json),
secret_paths,
} = &rule.action
{
// Fail-closed: a json capture with no secret_paths would parse and
// re-serialise the envelope unchanged, leaking the real credential.
if secret_paths.is_empty() {
report.error(
"capture_json_missing_secret_paths",
format!(
"command '{command_name}' intercept rule {i} capture format=json requires a non-empty secret_paths"
),
);
}
// An empty path string traverses zero keys and can never resolve to
// a string value; reject it rather than silently no-op.
if secret_paths.iter().any(|p| p.is_empty()) {
report.error(
"capture_json_empty_secret_path",
format!(
"command '{command_name}' intercept rule {i} capture secret_paths contains an empty path"
),
);
}
}
}
}

Expand Down Expand Up @@ -3746,6 +3809,136 @@ mod tests {
);
}

#[test]
fn validate_capture_json_requires_secret_paths() {
let mut config = active_git_config();
if let Some(git) = config.commands.get_mut("git") {
git.intercept = vec![InterceptRuleConfig {
args: vec!["credential".to_string()],
action: InterceptActionConfig::Capture {
format: Some(CaptureFormat::Json),
secret_paths: vec![],
},
}];
}
let report =
validate_command_policies(Some(&config), CommandPolicyValidationScope::Resolved);
assert!(
report
.errors
.iter()
.any(|f| f.code == "capture_json_missing_secret_paths"),
"json capture without secret_paths must be a fatal config error: {:?}",
report.errors
);
}

#[test]
fn validate_capture_json_rejects_empty_path() {
let mut config = active_git_config();
if let Some(git) = config.commands.get_mut("git") {
git.intercept = vec![InterceptRuleConfig {
args: vec!["credential".to_string()],
action: InterceptActionConfig::Capture {
format: Some(CaptureFormat::Json),
secret_paths: vec!["".to_string()],
},
}];
}
let report =
validate_command_policies(Some(&config), CommandPolicyValidationScope::Resolved);
assert!(
report
.errors
.iter()
.any(|f| f.code == "capture_json_empty_secret_path"),
"empty secret_path must be a fatal config error: {:?}",
report.errors
);
}

#[test]
fn validate_capture_json_with_paths_is_valid() {
let mut config = active_git_config();
if let Some(git) = config.commands.get_mut("git") {
git.intercept = vec![InterceptRuleConfig {
args: vec!["credential".to_string()],
action: InterceptActionConfig::Capture {
format: Some(CaptureFormat::Json),
secret_paths: vec!["claudeAiOauth.accessToken".to_string()],
},
}];
}
let report =
validate_command_policies(Some(&config), CommandPolicyValidationScope::Resolved);
assert!(
!report
.errors
.iter()
.any(|f| f.code.starts_with("capture_json")),
"valid json capture must not error: {:?}",
report.errors
);
}

#[test]
fn capture_action_serde_backward_compat() {
// Legacy bare form: no fields → opaque capture.
let opaque: InterceptActionConfig =
serde_json::from_str(r#"{"type":"capture"}"#).expect("bare capture must deserialize");
assert_eq!(
opaque,
InterceptActionConfig::Capture {
format: None,
secret_paths: vec![],
}
);
// It must also round-trip back to the bare form (fields skipped).
assert_eq!(
serde_json::to_value(&opaque).expect("serialize"),
serde_json::json!({"type": "capture"})
);

// New json form.
let json: InterceptActionConfig = serde_json::from_str(
r#"{"type":"capture","format":"json","secret_paths":["claudeAiOauth.accessToken"]}"#,
)
.expect("json capture must deserialize");
assert_eq!(
json,
InterceptActionConfig::Capture {
format: Some(CaptureFormat::Json),
secret_paths: vec!["claudeAiOauth.accessToken".to_string()],
}
);
}

#[test]
fn validate_capture_opaque_needs_no_secret_paths() {
// Backward compatibility: a bare `{"type":"capture"}` (format None)
// must remain valid with no secret_paths.
let mut config = active_git_config();
if let Some(git) = config.commands.get_mut("git") {
git.intercept = vec![InterceptRuleConfig {
args: vec!["credential".to_string()],
action: InterceptActionConfig::Capture {
format: None,
secret_paths: vec![],
},
}];
}
let report =
validate_command_policies(Some(&config), CommandPolicyValidationScope::Resolved);
assert!(
!report
.errors
.iter()
.any(|f| f.code.starts_with("capture_json")),
"opaque capture must not require secret_paths: {:?}",
report.errors
);
}

#[test]
fn validate_intercept_catch_all_last_is_valid() {
let mut config = active_git_config();
Expand Down
2 changes: 1 addition & 1 deletion crates/nono-cli/src/execution_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ pub(crate) fn execute_sandboxed(plan: LaunchPlan) -> Result<()> {
}

#[cfg(any(target_os = "linux", target_os = "macos"))]
let shared_broker = crate::tool_sandbox::token_broker::new_shared_broker();
let shared_broker = crate::proxy_runtime::build_shared_broker(proxy);
let active_proxy = start_proxy_runtime(
network,
&mut caps,
Expand Down
5 changes: 5 additions & 0 deletions crates/nono-cli/src/launch_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ pub(crate) struct ProxyLaunchOptions {
pub(crate) session_id: String,
/// Supervisor-side CLI command credential-capture entries.
pub(crate) credential_capture: HashMap<String, profile::CredentialCaptureEntry>,
/// Managed credential routes (e.g. OAuth-capture). Desugared into proxy
/// `RouteConfig`s and used to decide whether the token broker is backed by
/// a persistent keychain store.
pub(crate) credential_routes: Vec<profile::ManagedCredentialRoute>,
/// Enable HTTP/2 negotiation for upstream connections.
pub(crate) enable_h2: bool,
}
Expand All @@ -163,6 +167,7 @@ impl ProxyLaunchOptions {
.as_ref()
.is_some_and(|credentials| !credentials.credentials.is_empty())
|| self.upstream_proxy.is_some()
|| !self.credential_routes.is_empty()
}
}

Expand Down
2 changes: 2 additions & 0 deletions crates/nono-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ mod tests {
credentials: vec!["github".to_string()],
custom_credentials: std::collections::HashMap::new(),
credential_capture: std::collections::HashMap::new(),
credential_routes: Vec::new(),
tls_intercept: None,
upstream_proxy: None,
upstream_bypass: Vec::new(),
Expand Down Expand Up @@ -347,6 +348,7 @@ mod tests {
credentials: vec!["github".to_string()],
custom_credentials: std::collections::HashMap::new(),
credential_capture: std::collections::HashMap::new(),
credential_routes: Vec::new(),
tls_intercept: None,
upstream_proxy: None,
upstream_bypass: Vec::new(),
Expand Down
3 changes: 3 additions & 0 deletions crates/nono-cli/src/network_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ pub fn resolve_credentials(
env_var: cred.env_var.clone(),
endpoint_rules: cred.endpoint_rules.clone(),
endpoint_policy: cred.endpoint_policy.clone(),
oauth_capture: None,
tls_ca: cred
.tls_ca
.as_deref()
Expand Down Expand Up @@ -298,6 +299,7 @@ pub fn resolve_credentials(
env_var: cred.env_var.clone(),
endpoint_rules: cred.endpoint_rules.clone(),
endpoint_policy: None,
oauth_capture: None,
tls_ca: None, // Built-in credentials don't support custom CAs
tls_client_cert: None,
tls_client_key: None,
Expand Down Expand Up @@ -426,6 +428,7 @@ pub fn partition_allow_domain(
env_var: None,
endpoint_rules: endpoints.clone(),
endpoint_policy: None,
oauth_capture: None,
tls_ca: None,
tls_client_cert: None,
tls_client_key: None,
Expand Down
1 change: 1 addition & 0 deletions crates/nono-cli/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ impl ProfileDef {
environment: None,
command_policies: self.command_policies.clone(),
credential_capture: HashMap::new(),
credential_routes: Vec::new(),
workdir: self.workdir.clone(),
hooks: self.hooks.clone(),
session_hooks: profile::SessionHooks::default(),
Expand Down
Loading
Loading