Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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