use openmoat_core::ir::BrokeredSecret;
use openmoat_core::{Kind, SecretSource};
use serde_json::{Map, Value, json};
use crate::sandbox::Report;
pub(super) fn credentials(
secrets: &[BrokeredSecret],
network: &mut Value,
report: &mut Report,
) -> Option<Value> {
let allowed: Vec<String> = network
.pointer("/allowedDomains")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect();
let mut entries = entries(secrets, &allowed, report);
if entries.is_empty() {
return None;
}
let can_lift_tls = entries.iter().all(|e| e.plain_http);
if !can_lift_tls {
drop_plain_http(&mut entries, report);
}
if entries.is_empty() {
return None;
}
network["tlsTerminate"] = json!({});
Some(block(entries, can_lift_tls, report))
}
fn entries(secrets: &[BrokeredSecret], allowed: &[String], report: &mut Report) -> Vec<Entry> {
let mut entries: Vec<Entry> = Vec::new();
for secret in secrets {
if !allowed.iter().any(|h| h == &secret.host) {
report.loss(
Kind::Net,
&format!("secrets.{}", secret.id),
format!(
"`{}` is not in `net` allow, so Claude Code's broker cannot inject it; the \
secret still works through `openmoat proxy`",
secret.host
),
);
continue;
}
let (kind, value) = match &secret.source {
SecretSource::Env(name) => (
EntryKind::EnvVar,
json!({
"name": name,
"mode": "mask",
"injectHosts": [&secret.host],
}),
),
SecretSource::File(path) => (
EntryKind::File,
json!({
"path": path,
"mode": "mask",
"injectHosts": [&secret.host],
}),
),
SecretSource::Keychain { .. } => {
report.loss(
Kind::Net,
&format!("secrets.{}", secret.id),
"Claude Code's broker reads env and file sources only, not the keychain; the \
secret still works through `openmoat proxy`"
.into(),
);
continue;
}
};
entries.push(Entry {
id: secret.id.clone(),
plain_http: secret.plain_http,
kind,
value,
});
}
entries
}
fn drop_plain_http(entries: &mut Vec<Entry>, report: &mut Report) {
entries.retain(|e| {
if e.plain_http {
report.loss(
Kind::Net,
&format!("secrets.{}", e.id),
"mixing `plain_http: true` with `plain_http: false` secrets would widen the \
others through Claude Code's block-scoped `allowPlaintextInject`; this secret \
is routed through `openmoat proxy` instead"
.into(),
);
false
} else {
true
}
});
}
fn block(entries: Vec<Entry>, can_lift_tls: bool, report: &mut Report) -> Value {
let mut env_vars = Vec::new();
let mut files = Vec::new();
let mut masked = Vec::new();
for entry in entries {
match entry.kind {
EntryKind::EnvVar => env_vars.push(entry.value),
EntryKind::File => files.push(entry.value),
}
masked.push(entry.id);
}
let mut credentials = Map::new();
if !env_vars.is_empty() {
credentials.insert("envVars".into(), Value::Array(env_vars));
}
if !files.is_empty() {
credentials.insert("files".into(), Value::Array(files));
}
if can_lift_tls {
credentials.insert("allowPlaintextInject".into(), json!(true));
report.allowance(
Kind::Net,
"claude-code.plain-http-inject",
masked.clone(),
"sandboxed commands may send one of these masked secrets to a plain-HTTP destination; \
the value then goes in clear text",
);
}
report.allowance(
Kind::Net,
"claude-code.mask-credentials",
masked,
"Claude Code's own broker substitutes these inside HTTPS for sandboxed commands \
(`sandbox.credentials` with `tlsTerminate`); on macOS a `file` source is denied \
instead of masked, so the tool that reads it does not authenticate",
);
Value::Object(credentials)
}
struct Entry {
id: String,
plain_http: bool,
kind: EntryKind,
value: Value,
}
enum EntryKind {
EnvVar,
File,
}