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
8 changes: 8 additions & 0 deletions crates/nono-cli/data/nono-profile.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,10 @@
"enum": ["opaque", "jwt"],
"default": "opaque",
"description": "Visible phantom format. Use jwt only when a client locally parses the token as a JWT and does not resend it as a bearer token."
},
"format": {
"type": "string",
"description": "Optional literal template for the visible phantom, with '{}' standing in for a freshly minted random body (e.g. 'sk-ant-oat01-{}'). The phantom follows the template exactly so a client that classifies a credential by sniffing a literal token prefix still recognises it. Stripped on egress before the real credential is substituted. Only valid with kind 'opaque'."
}
}
}
Expand Down Expand Up @@ -1352,6 +1356,10 @@
"tls_client_key": {
"type": "string",
"description": "Optional PEM client private key for proxy credential mutual TLS. Must be paired with tls_client_cert."
},
"format": {
"type": "string",
"description": "Optional literal template for the visible phantom the sandbox sees, with '{}' standing in for a freshly minted random body (e.g. 'sk-ant-oat01-{}'). Lets a client that classifies a credential by sniffing a literal token prefix recognise the phantom. Only valid for ambient credentials."
}
}
},
Expand Down
4 changes: 2 additions & 2 deletions crates/nono-cli/data/profile-authoring-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,7 @@ generated trust bundle.
"host": "https://platform.claude.com",
"path": "/v1/oauth/token",
"response_fields": [
{ "path": "access_token", "kind": "opaque" },
{ "path": "access_token", "kind": "opaque", "format": "sk-ant-oat01-{}" },
{ "path": "refresh_token", "kind": "opaque" },
{ "path": "id_token", "kind": "jwt" }
],
Expand Down Expand Up @@ -635,7 +635,7 @@ generated trust bundle.
| `type` | string | yes | Currently `oauth_capture`. |
| `token_endpoints` | array | yes | HTTPS OAuth token origins and exact paths whose JSON responses are captured and rewritten to phantom tokens. Configure every token-bearing path the client may use. |
| `api_hosts` | array | yes | HTTPS API URL origins where this provider's phantom tokens may be resolved on egress. |
| `response_fields` | array | yes | Token response fields to rewrite. Each entry declares a `path` and a visible phantom `kind` of `opaque` or `jwt`; use `jwt` only for locally parsed fields, not bearer tokens resent upstream. |
| `response_fields` | array | yes | Token response fields to rewrite. Each entry declares a `path` and a visible phantom `kind` of `opaque` or `jwt`; use `jwt` only for locally parsed fields, not bearer tokens resent upstream. An optional `format` (e.g. `"sk-ant-oat01-{}"`, `kind: opaque` only) shapes the visible phantom — `{}` is a random body — so a client that classifies a credential by its literal prefix recognises it; the template is stripped on egress. |
| `request_body` | string | no | Token request body format for refresh/exchange rewriting: `auto`, `json`, or `form`. |
| `credential_store` | object | no | Optional session/logout detection, such as a keychain JSON record or file JSON record with fields expected to contain phantoms. |
| `helpers` | object | no | Optional status, login, and logout commands for humans or CLI workflows. Commands are arrays and are not run through a shell. |
Expand Down
147 changes: 145 additions & 2 deletions crates/nono-cli/src/command_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,11 @@ pub struct CommandCredentialConfig {
pub tls_client_key: Option<String>,
#[serde(default)]
pub source: Option<AmbientCredentialSourceConfig>,
/// Literal template for the visible phantom, `{}` standing in for the random
/// body (e.g. `"sk-ant-oat01-{}"`), so a client that classifies a credential by
/// sniffing a token prefix still recognises it. `ambient` credentials only.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
}

impl Default for CommandCredentialConfig {
Expand All @@ -430,6 +435,7 @@ impl Default for CommandCredentialConfig {
tls_client_cert: None,
tls_client_key: None,
source: None,
format: None,
}
}
}
Expand Down Expand Up @@ -1742,14 +1748,28 @@ fn validate_intercept_rules(
format!("command '{command_name}' intercept rule {i} respond stdout exceeds 1 MiB"),
);
}
if let InterceptActionConfig::CaptureCredential { credential, .. } = &rule.action {
if let InterceptActionConfig::CaptureCredential {
credential, shape, ..
} = &rule.action
{
validate_identifier(
&format!("commands.{command_name}.intercept[{i}].action.credential"),
credential,
report,
);
match config.credentials.get(credential) {
Some(config) if config.credential_type == CommandCredentialType::Ambient => {}
Some(cred) if cred.credential_type == CommandCredentialType::Ambient => {
// A `format` here would land inside the JWT signature
// segment rather than shaping the visible token.
if *shape == CapturedNonceShape::Jwt && cred.format.is_some() {
report.error(
"invalid_credential_capture",
format!(
"command '{command_name}' intercept rule {i} capture_credential shape 'jwt' cannot be combined with credential '{credential}' format"
),
);
}
}
Some(_) => {
report.error(
"invalid_credential_capture",
Expand Down Expand Up @@ -2420,6 +2440,19 @@ fn validate_credential(
credential: &CommandCredentialConfig,
report: &mut CommandPolicyValidationReport,
) {
if let Some(template) = &credential.format {
if credential.credential_type != CommandCredentialType::Ambient {
report.error(
"invalid_credential",
format!("credential '{name}' format is only valid for ambient credentials"),
);
} else if let Err(err) = nono_proxy::token::PhantomTemplate::parse(template) {
report.error(
"invalid_credential",
format!("ambient credential '{name}' {err}"),
);
}
}
match credential.credential_type {
CommandCredentialType::LocalSocket => {
if credential.path.as_deref().unwrap_or_default().is_empty() {
Expand Down Expand Up @@ -5952,4 +5985,114 @@ mod tests {
serde_json::from_str("{}").expect("missing entries should default to empty");
assert!(restored.entries.is_empty());
}

fn validate_one(credential: &CommandCredentialConfig) -> CommandPolicyValidationReport {
let mut report = CommandPolicyValidationReport::default();
validate_credential("cred", credential, &mut report);
report
}

#[test]
fn ambient_format_single_placeholder_accepted() {
let cred = CommandCredentialConfig {
credential_type: CommandCredentialType::Ambient,
format: Some("sk-ant-oat01-{}".to_string()),
..Default::default()
};
assert!(
validate_one(&cred).errors.is_empty(),
"single-placeholder ambient format should validate"
);
}

#[test]
fn ambient_format_with_control_characters_rejected() {
let cred = CommandCredentialConfig {
credential_type: CommandCredentialType::Ambient,
format: Some("a\r\nX: y{}".to_string()),
..Default::default()
};
let errors = validate_one(&cred).errors;
assert!(
errors
.iter()
.any(|e| e.message.contains("must not contain control characters")),
"expected control-character error, got {errors:?}"
);
}

fn capture_credential_config(
format: Option<&str>,
shape: CapturedNonceShape,
) -> CommandPoliciesConfig {
let mut config = active_git_config();
config.credentials.insert(
"anthropic".to_string(),
CommandCredentialConfig {
credential_type: CommandCredentialType::Ambient,
format: format.map(str::to_string),
..Default::default()
},
);
let git = config.commands.get_mut("git").expect("git command");
git.intercept.push(InterceptRuleConfig {
args: Some(vec!["status".to_string()]),
match_config: None,
action: InterceptActionConfig::CaptureCredential {
credential: "anthropic".to_string(),
grant_to: Vec::new(),
shape,
},
sandbox: None,
});
config
}

fn capture_shape_errors(format: Option<&str>, shape: CapturedNonceShape) -> Vec<String> {
validate_command_policies(
Some(&capture_credential_config(format, shape)),
CommandPolicyValidationScope::Resolved,
)
.errors
.into_iter()
.filter(|finding| finding.code == "invalid_credential_capture")
.map(|finding| finding.message)
.collect()
}

#[test]
fn ambient_format_with_jwt_capture_shape_rejected() {
let errors = capture_shape_errors(Some("sk-ant-oat01-{}"), CapturedNonceShape::Jwt);
assert!(
errors
.iter()
.any(|message| message.contains("shape 'jwt' cannot be combined")),
"expected jwt-shape/format conflict, got {errors:?}"
);
}

#[test]
fn ambient_format_with_opaque_capture_shape_accepted() {
assert!(
capture_shape_errors(Some("sk-ant-oat01-{}"), CapturedNonceShape::Opaque).is_empty()
);
assert!(capture_shape_errors(None, CapturedNonceShape::Jwt).is_empty());
}

#[test]
fn format_rejected_on_non_ambient_credential() {
let cred = CommandCredentialConfig {
credential_type: CommandCredentialType::Proxy,
format: Some("sk-{}".to_string()),
upstream: Some("https://example.com".to_string()),
inject_header: Some("Authorization".to_string()),
..Default::default()
};
assert!(
validate_one(&cred)
.errors
.iter()
.any(|e| e.message.contains("only valid for ambient credentials"))
);
}
}
32 changes: 32 additions & 0 deletions crates/nono-cli/src/profile/credential_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ pub struct CredentialProviderResponseField {
pub path: String,
#[serde(default)]
pub kind: CredentialProviderResponseFieldKind,
/// Literal template for the visible phantom, `{}` standing in for the random
/// body (e.g. `"sk-ant-oat01-{}"`), so a client that classifies a credential by
/// sniffing a token prefix still recognises it. `kind: opaque` only.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
Expand Down Expand Up @@ -178,6 +183,15 @@ pub(super) fn validate_credential_provider_entries(profile: &Profile) -> Result<
),
&field.path,
)?;
if let Some(template) = &field.format {
validate_provider_format(
&format!(
"credential_providers.{name}.token_endpoints[{index}].response_fields.format"
),
template,
field.kind,
)?;
}
}
// request_nonce_fields is only meaningful for refresh/exchange
// flows that re-send a phantom in the request body. Capture-only
Expand Down Expand Up @@ -360,6 +374,23 @@ fn validate_phantom_fields(provider_name: &str, fields: &[String]) -> Result<()>
Ok(())
}

/// A JWT phantom's shape is already fixed by its three segments, so a `format`
/// there would have nothing to shape.
fn validate_provider_format(
context: &str,
template: &str,
kind: CredentialProviderResponseFieldKind,
) -> Result<()> {
if kind != CredentialProviderResponseFieldKind::Opaque {
return Err(NonoError::ProfileParse(format!(
"{context} is only valid with kind 'opaque'"
)));
}
nono_proxy::token::PhantomTemplate::parse(template)
.map_err(|err| NonoError::ProfileParse(format!("{context} {err}")))?;
Ok(())
}

fn validate_optional_helper_command(
provider_name: &str,
helper_name: &str,
Expand Down Expand Up @@ -494,6 +525,7 @@ mod tests {
response_fields: vec![CredentialProviderResponseField {
path: "auth.client_token".to_string(),
kind: CredentialProviderResponseFieldKind::Opaque,
format: None,
}],
request_body: CredentialProviderRequestBodyFormat::Auto,
// Capture-only endpoint: intentionally no request_nonce_fields.
Expand Down
83 changes: 83 additions & 0 deletions crates/nono-cli/src/profile/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9155,6 +9155,89 @@ mod tests {
assert_eq!(profile.credential_routes[0].provider, "claude_code");
}

#[test]
fn credential_provider_format_opaque_single_placeholder_parses() {
let json = br#"{
"meta": { "name": "fmt-ok" },
"credential_providers": {
"claude_code": {
"type": "oauth_capture",
"token_endpoints": [{
"host": "https://platform.claude.com",
"path": "/v1/oauth/token",
"response_fields": [
{ "path": "access_token", "kind": "opaque", "format": "sk-ant-oat01-{}" }
],
"request_nonce_fields": ["refresh_token"]
}],
"api_hosts": ["https://api.anthropic.com"]
}
}
}"#;
let profile = parse_profile_bytes(json).expect("format profile parses");
finalize_profile(profile.clone()).expect("opaque single-placeholder format validates");
let field = &profile
.credential_providers
.get("claude_code")
.expect("provider")
.token_endpoints[0]
.response_fields[0];
assert_eq!(field.format.as_deref(), Some("sk-ant-oat01-{}"));
}

#[test]
fn credential_provider_format_rejected_with_jwt_kind() {
let json = br#"{
"meta": { "name": "fmt-jwt" },
"credential_providers": {
"claude_code": {
"type": "oauth_capture",
"token_endpoints": [{
"host": "https://platform.claude.com",
"path": "/v1/oauth/token",
"response_fields": [
{ "path": "access_token", "kind": "jwt", "format": "sk-{}" }
],
"request_nonce_fields": ["refresh_token"]
}],
"api_hosts": ["https://api.anthropic.com"]
}
}
}"#;
let err = parse_profile_bytes(json).expect_err("format+jwt should reject");
assert!(
err.to_string().contains("only valid with kind 'opaque'"),
"unexpected error: {err}"
);
}

#[test]
fn credential_provider_format_rejects_control_characters() {
let json = br#"{
"meta": { "name": "fmt-crlf" },
"credential_providers": {
"claude_code": {
"type": "oauth_capture",
"token_endpoints": [{
"host": "https://platform.claude.com",
"path": "/v1/oauth/token",
"response_fields": [
{ "path": "access_token", "kind": "opaque", "format": "a\r\nX: y{}" }
],
"request_nonce_fields": ["refresh_token"]
}],
"api_hosts": ["https://api.anthropic.com"]
}
}
}"#;
let err = parse_profile_bytes(json).expect_err("CRLF-bearing format should reject");
assert!(
err.to_string()
.contains("must not contain control characters"),
"unexpected error: {err}"
);
}

#[test]
fn test_profile_credential_route_rejects_unknown_provider() {
let json = br#"{
Expand Down
Loading