Skip to content

Commit 1a09a69

Browse files
hanwenchengclaude
andcommitted
fix(cli/mock): #19 codex P2 fixes — credential cache + audit DENIED row
P2-1 (cli): cmd_run was issuing two GET /credential/read calls when an --env override targeted a service that auto-injection had already fetched. Cache successful reads in a HashMap<service, ciphertext> and reuse the cached value during --env resolution. Halves credential RTTs for the common "auto-inject + override" flow. P2-2 (mock): list_credentials returned 403 on cross-agent attempts without leaving an audit trail, so probing through the new /credential/list endpoint stayed invisible. Mirror the read_credential contract by inserting a 'list' / 'DENIED' audit row (service_name='*') before the 403 return. Extend integration test list_credentials_ownership_enforced to assert the DENIED row appears. Test: cargo test -p agentkeys-cli -p agentkeys-mock-server -- --test-threads=1 mock-server: 40 passed; cli: 18 passed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 11e35e7 commit 1a09a69

3 files changed

Lines changed: 57 additions & 7 deletions

File tree

crates/agentkeys-cli/src/lib.rs

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,12 @@ pub async fn cmd_run(
183183
}
184184
}
185185

186+
// Track which services we've already fetched in the auto-injection pass.
187+
// The --env loop below reuses these values instead of issuing a second
188+
// read_credential for the same service, which would double-count audit
189+
// events and rate-limit decrements (codex P2 on PR #19).
190+
let mut fetched: std::collections::HashMap<String, String> =
191+
std::collections::HashMap::new();
186192
let mut env_vars: Vec<(String, String)> = Vec::new();
187193
let mut credential_errors: Vec<String> = Vec::new();
188194
for service in &services_to_try {
@@ -191,6 +197,7 @@ pub async fn cmd_run(
191197
Ok(bytes) => {
192198
let value = String::from_utf8_lossy(&bytes).to_string();
193199
let env_key = format!("{}_API_KEY", service.to_uppercase().replace('-', "_"));
200+
fetched.insert(service.clone(), value.clone());
194201
env_vars.push((env_key, value));
195202
}
196203
Err(e) => {
@@ -215,12 +222,26 @@ pub async fn cmd_run(
215222
})?;
216223
let env_key = raw[..eq_pos].to_string();
217224
let service = &raw[eq_pos + 1..];
218-
let service_name = ServiceName(service.to_string());
219-
let bytes = backend
220-
.read_credential(&session, &agent_id, &service_name)
221-
.await
222-
.map_err(wrap_backend_error)?;
223-
let value = String::from_utf8_lossy(&bytes).to_string();
225+
226+
// Reuse the auto-injection fetch if we already pulled this service.
227+
// Only issue a fresh read_credential when --env names a service that
228+
// wasn't auto-injected (typical for master sessions where scope=None
229+
// → all stored services were already pulled, so fresh reads here are
230+
// for the rare case of explicit --env on a service the user never
231+
// stored before this run).
232+
let value = if let Some(cached) = fetched.get(service) {
233+
cached.clone()
234+
} else {
235+
let service_name = ServiceName(service.to_string());
236+
let bytes = backend
237+
.read_credential(&session, &agent_id, &service_name)
238+
.await
239+
.map_err(wrap_backend_error)?;
240+
let v = String::from_utf8_lossy(&bytes).to_string();
241+
fetched.insert(service.to_string(), v.clone());
242+
v
243+
};
244+
224245
if let Some(existing) = env_vars.iter_mut().find(|(k, _)| k == &env_key) {
225246
existing.1 = value;
226247
} else {

crates/agentkeys-mock-server/src/handlers/credential.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,18 @@ pub async fn list_credentials(
201201
let db = state.db.lock().unwrap();
202202

203203
if !is_owner_of(&db, &session.wallet_address, agent_id) {
204+
// Audit the DENIED list attempt so cross-agent probing through the
205+
// new /credential/list path stays visible in the audit log — the
206+
// existing read_credential audit contract guarantees DENIED rows for
207+
// ownership failures, and this endpoint inherits the same use case
208+
// (called from cmd_run for master sessions). Codex P2 on PR #19.
209+
let now = now_secs();
210+
db.execute(
211+
"INSERT INTO audit_log (owner_wallet, agent_wallet, service_name, action, result, timestamp)
212+
VALUES (?1, ?2, ?3, 'list', 'DENIED', ?4)",
213+
params![session.wallet_address, agent_id, "*", now],
214+
)
215+
.ok();
204216
return Err(AppError::forbidden(format!(
205217
"session does not own agent {}",
206218
agent_id

crates/agentkeys-mock-server/tests/integration.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1349,6 +1349,23 @@ async fn list_credentials_ownership_enforced() {
13491349
let session_b = json_b["session"].as_str().unwrap().to_string();
13501350

13511351
let path = format!("/credential/list?agent_id={}", wallet_a);
1352-
let (status, _) = get_json_auth(app, &path, &session_b).await;
1352+
let (status, _) = get_json_auth(app.clone(), &path, &session_b).await;
13531353
assert_eq!(status, StatusCode::FORBIDDEN, "user B must not list user A's credentials");
1354+
1355+
// Codex P2 on PR #19: a denied list_credentials must also leave an audit
1356+
// trail so cross-agent probing through the new /credential/list endpoint
1357+
// is visible. Query the audit log via the existing /audit endpoint
1358+
// (filtered by agent=wallet_a; user A can see events where their wallet is
1359+
// the agent_wallet, even when owner_wallet is user B). Confirm a DENIED
1360+
// 'list' row appears.
1361+
let audit_path = format!("/audit/query?agent={}", wallet_a);
1362+
let (audit_status, audit_body) = get_json_auth(app, &audit_path, &session_a).await;
1363+
assert_eq!(audit_status, StatusCode::OK, "audit query failed: {audit_body}");
1364+
let events = audit_body["events"].as_array().expect("events array");
1365+
assert!(
1366+
events
1367+
.iter()
1368+
.any(|e| e["action"] == "list" && e["result"] == "DENIED"),
1369+
"expected a list/DENIED audit row after the cross-agent list attempt, got: {audit_body}"
1370+
);
13541371
}

0 commit comments

Comments
 (0)