Skip to content

Commit f141233

Browse files
authored
fix(agent): guard internal Aion CLI command overrides (#538)
## Summary - reject command overrides for the internal Aion CLI agent - ignore and mask legacy Aion CLI command overrides at registry/read time - add migration 016 to clear polluted internal Aion CLI override data ## Tests - CARGO_TARGET_DIR=/tmp/aioncore-codex-target cargo test -p aionui-ai-agent decode_row_ignores_internal_aion_cli_command_override - CARGO_TARGET_DIR=/tmp/aioncore-codex-target cargo test -p aionui-app --test agent_integration_e2e internal_aion_cli_rejects_command_override - CARGO_TARGET_DIR=/tmp/aioncore-codex-target cargo test -p aionui-db --test cron_assistant_first_migration migration_016_clears_internal_aion_cli_command_override_only - CARGO_TARGET_DIR=/tmp/aioncore-codex-target cargo fmt --all -- --check - CARGO_TARGET_DIR=/tmp/aioncore-codex-target cargo test -p aionui-ai-agent - CARGO_TARGET_DIR=/tmp/aioncore-codex-target cargo test -p aionui-db - CARGO_TARGET_DIR=/tmp/aioncore-codex-target cargo clippy -p aionui-ai-agent -p aionui-db -p aionui-app -- -D warnings - CARGO_TARGET_DIR=/tmp/aioncore-codex-target cargo test -p aionui-app --test agent_integration_e2e - CARGO_TARGET_DIR=/tmp/aioncore-codex-target just push -u origin fix/internal-aion-cli-override-guard ## Investigation notes Sentry #81 shows PUT /api/agents/632f31d2/overrides polluted the internal Aion CLI row with command_override values like a web URL and `irm https://claude.ai/install.ps1 | iex`. The backend must treat this internal commandless agent as non-overridable regardless of caller. --------- Co-authored-by: zk <>
1 parent 1f38d96 commit f141233

5 files changed

Lines changed: 229 additions & 17 deletions

File tree

crates/aionui-ai-agent/src/registry.rs

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,8 @@ impl AgentRegistry {
191191
for meta in guard.values_mut() {
192192
let (path, reason) = probe_with_reason(meta);
193193
meta.resolved_command = path;
194-
meta.available = meta.resolved_command.is_some()
195-
|| (meta.enabled && meta.command.is_none() && meta.agent_source == AgentSource::Internal);
194+
meta.available = meta.resolved_command.is_some() || is_internal_commandless_agent(meta);
195+
let reason = if meta.available { None } else { reason };
196196
log_probe_result(meta, &reason);
197197
}
198198
log_availability_summary(guard.values(), "AgentRegistry refresh_availability complete");
@@ -464,15 +464,37 @@ fn decode_row(row: AgentMetadataRow) -> Option<(AgentMetadata, Option<Unavailabl
464464
// Layered on top of seed truth at this single projection point so both
465465
// the runtime spawn (factory) and the probe (availability) observe the
466466
// same merged command/env without either needing extra plumbing.
467-
meta.has_command_override = meta_command_override(&command_override_raw).is_some();
468-
meta.env_override_key_count = parse_env_override(&env_override_raw)
467+
let command_override = meta_command_override(&command_override_raw);
468+
let is_internal_aion_cli = is_internal_aion_cli(&meta);
469+
if is_internal_aion_cli && command_override.is_some() {
470+
warn!(
471+
id = %meta.id,
472+
name = %meta.name,
473+
"Ignoring command override for internal Aion CLI agent"
474+
);
475+
}
476+
let env_override = parse_env_override(&env_override_raw);
477+
if is_internal_aion_cli && env_override.as_ref().is_some_and(|entries| !entries.is_empty()) {
478+
warn!(
479+
id = %meta.id,
480+
name = %meta.name,
481+
"Ignoring environment overrides for internal Aion CLI agent"
482+
);
483+
}
484+
485+
meta.has_command_override = command_override.is_some() && !is_internal_aion_cli;
486+
meta.env_override_key_count = env_override
487+
.as_ref()
488+
.filter(|_| !is_internal_aion_cli)
469489
.map(|v| v.iter().filter(|e| !is_blocked_override_env_key(&e.name)).count())
470490
.unwrap_or(0);
471491

472-
if let Some(path) = meta_command_override(&command_override_raw) {
492+
if is_internal_aion_cli {
493+
meta.command = None;
494+
} else if let Some(path) = command_override {
473495
meta.command = Some(path);
474496
}
475-
if let Some(extra) = parse_env_override(&env_override_raw) {
497+
if !is_internal_aion_cli && let Some(extra) = env_override {
476498
for entry in extra {
477499
if is_blocked_override_env_key(&entry.name) {
478500
tracing::warn!(key = %entry.name, "env override: blocked key skipped");
@@ -484,11 +506,19 @@ fn decode_row(row: AgentMetadataRow) -> Option<(AgentMetadata, Option<Unavailabl
484506

485507
let (path, reason) = probe_with_reason(&meta);
486508
meta.resolved_command = path;
487-
meta.available = meta.resolved_command.is_some()
488-
|| (meta.enabled && meta.command.is_none() && meta.agent_source == AgentSource::Internal);
509+
meta.available = meta.resolved_command.is_some() || is_internal_commandless_agent(&meta);
510+
let reason = if meta.available { None } else { reason };
489511
Some((meta, reason))
490512
}
491513

514+
fn is_internal_aion_cli(meta: &AgentMetadata) -> bool {
515+
meta.agent_type == AgentType::Aionrs && meta.agent_source == AgentSource::Internal
516+
}
517+
518+
fn is_internal_commandless_agent(meta: &AgentMetadata) -> bool {
519+
meta.enabled && meta.command.is_none() && meta.agent_source == AgentSource::Internal
520+
}
521+
492522
/// Wrapper around [`probe_resolved_command`] that returns both the
493523
/// resolved path (if any) and the failure reason as a tuple, so the
494524
/// hydrate / refresh loops can persist the path and emit a single
@@ -1294,6 +1324,61 @@ mod tests {
12941324
assert_eq!(meta.command.as_deref(), Some("/opt/factory/bin/droid"));
12951325
}
12961326

1327+
#[test]
1328+
fn decode_row_ignores_internal_aion_cli_overrides() {
1329+
use aionui_db::AgentMetadataRow;
1330+
let row = AgentMetadataRow {
1331+
id: "632f31d2".to_string(),
1332+
icon: None,
1333+
name: "Aion CLI".to_string(),
1334+
name_i18n: None,
1335+
description: None,
1336+
description_i18n: None,
1337+
backend: None,
1338+
agent_type: "aionrs".to_string(),
1339+
agent_source: "internal".to_string(),
1340+
agent_source_info: None,
1341+
enabled: true,
1342+
command: None,
1343+
command_override: Some("irm https://claude.ai/install.ps1 | iex".to_string()),
1344+
args: None,
1345+
env: None,
1346+
native_skills_dirs: None,
1347+
behavior_policy: None,
1348+
yolo_id: None,
1349+
agent_capabilities: None,
1350+
auth_methods: None,
1351+
config_options: None,
1352+
available_modes: None,
1353+
available_models: None,
1354+
available_commands: None,
1355+
sort_order: 0,
1356+
last_check_status: None,
1357+
last_check_kind: None,
1358+
last_check_error_code: None,
1359+
last_check_error_message: None,
1360+
last_check_guidance: None,
1361+
last_check_latency_ms: None,
1362+
last_check_at: None,
1363+
last_success_at: None,
1364+
last_failure_at: None,
1365+
env_override: Some(
1366+
r#"[{"name":"ANTHROPIC_API_KEY","value":"sk-x"},{"name":"PATH","value":"/evil"}]"#.to_string(),
1367+
),
1368+
created_at: 0,
1369+
updated_at: 0,
1370+
};
1371+
let (meta, reason) = super::decode_row(row).expect("decodes");
1372+
assert_eq!(meta.agent_type, AgentType::Aionrs);
1373+
assert_eq!(meta.agent_source, AgentSource::Internal);
1374+
assert_eq!(meta.command, None);
1375+
assert!(!meta.has_command_override);
1376+
assert_eq!(meta.env_override_key_count, 0);
1377+
assert!(meta.env.is_empty());
1378+
assert!(meta.available);
1379+
assert!(reason.is_none());
1380+
}
1381+
12971382
#[test]
12981383
fn decode_row_appends_env_override_and_skips_blocked() {
12991384
use aionui_db::AgentMetadataRow;

crates/aionui-ai-agent/src/services/agent.rs

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,8 @@ impl AgentService {
140140
req: aionui_api_types::SetAgentOverridesRequest,
141141
) -> Result<AgentManagementRow, AgentError> {
142142
let repo = self.registry.repo_handle();
143-
repo.get(id)
143+
let row = repo
144+
.get(id)
144145
.await
145146
.map_err(|e| AgentError::internal(format!("repo.get: {e}")))?
146147
.ok_or_else(|| AgentError::not_found(format!("Agent '{id}' not found")))?;
@@ -151,6 +152,14 @@ impl AgentService {
151152
.map(str::trim)
152153
.filter(|s| !s.is_empty())
153154
.map(str::to_owned);
155+
let has_env_override = req
156+
.env_override
157+
.as_ref()
158+
.is_some_and(|entries| entries.iter().any(|entry| !entry.name.trim().is_empty()));
159+
160+
if (command_override.is_some() || has_env_override) && is_internal_aion_cli_row(&row) {
161+
return Err(AgentError::bad_request("Internal Aion CLI does not support overrides"));
162+
}
154163

155164
let env_json = match req.env_override {
156165
Some(entries) if !entries.is_empty() => Some(
@@ -163,12 +172,8 @@ impl AgentService {
163172
repo.update_agent_overrides(id, command_override.as_deref(), env_json.as_deref())
164173
.await
165174
.map_err(|e| AgentError::internal(format!("repo.update_agent_overrides: {e}")))?;
166-
self.registry.invalidate_and_rehydrate().await?;
167175

168-
self.availability
169-
.management_row_by_id(id)
170-
.await
171-
.ok_or_else(|| AgentError::not_found(format!("Agent '{id}' not found")))
176+
self.availability.run_manual_health_check(id).await
172177
}
173178

174179
pub async fn get_agent_overrides(&self, id: &str) -> Result<aionui_api_types::AgentOverridesResponse, AgentError> {
@@ -187,8 +192,16 @@ impl AgentService {
187192
.unwrap_or_default();
188193

189194
Ok(aionui_api_types::AgentOverridesResponse {
190-
command_override: row.command_override,
195+
command_override: if is_internal_aion_cli_row(&row) {
196+
None
197+
} else {
198+
row.command_override
199+
},
191200
env_override,
192201
})
193202
}
194203
}
204+
205+
fn is_internal_aion_cli_row(row: &aionui_db::AgentMetadataRow) -> bool {
206+
row.agent_type.eq_ignore_ascii_case("aionrs") && row.agent_source.eq_ignore_ascii_case("internal")
207+
}

crates/aionui-app/tests/agent_integration_e2e.rs

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -628,12 +628,15 @@ async fn agent_overrides_roundtrip_and_management_summary() {
628628

629629
// PUT overrides
630630
let body = json!({
631-
"command_override": "/real/bin/ovr",
631+
"command_override": "true",
632632
"env_override": [{"name": "ANTHROPIC_API_KEY", "value": "sk-x"}, {"name": "PATH", "value": "/evil"}]
633633
});
634634
let req = json_with_token("PUT", "/api/agents/ovr-agent/overrides", body, &token, &csrf);
635635
let resp = app.clone().oneshot(req).await.unwrap();
636636
assert_eq!(resp.status(), StatusCode::OK);
637+
let put_body = body_json(resp).await;
638+
assert_eq!(put_body["data"]["last_check_kind"], "manual");
639+
assert_eq!(put_body["data"]["last_check_status"], "offline");
637640

638641
// management row: safe fields, blocked PATH not counted
639642
let mreq = get_with_token("/api/agents/management", &token);
@@ -659,7 +662,35 @@ async fn agent_overrides_roundtrip_and_management_summary() {
659662
// GET overrides: plaintext echo
660663
let greq = get_with_token("/api/agents/ovr-agent/overrides", &token);
661664
let gbody = body_json(app.clone().oneshot(greq).await.unwrap()).await;
662-
assert_eq!(gbody["data"]["command_override"], "/real/bin/ovr");
665+
assert_eq!(gbody["data"]["command_override"], "true");
663666
let envs = gbody["data"]["env_override"].as_array().unwrap();
664667
assert!(envs.iter().any(|e| e["name"] == "ANTHROPIC_API_KEY"));
665668
}
669+
670+
#[tokio::test]
671+
async fn internal_aion_cli_rejects_overrides() {
672+
let (mut app, services, _mock_tm) = build_app_with_mock_tasks().await;
673+
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "Pass123!").await;
674+
upsert_visible_agent_metadata(&services, "632f31d2", "aionrs").await;
675+
services.agent_registry.invalidate_and_rehydrate().await.unwrap();
676+
677+
let command_body = json!({
678+
"command_override": "irm https://claude.ai/install.ps1 | iex",
679+
"env_override": [{"name": "ANTHROPIC_API_KEY", "value": "sk-x"}]
680+
});
681+
let req = json_with_token("PUT", "/api/agents/632f31d2/overrides", command_body, &token, &csrf);
682+
let resp = app.clone().oneshot(req).await.unwrap();
683+
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
684+
685+
let env_body = json!({
686+
"env_override": [{"name": "ANTHROPIC_API_KEY", "value": "sk-x"}]
687+
});
688+
let req = json_with_token("PUT", "/api/agents/632f31d2/overrides", env_body, &token, &csrf);
689+
let resp = app.clone().oneshot(req).await.unwrap();
690+
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
691+
692+
let greq = get_with_token("/api/agents/632f31d2/overrides", &token);
693+
let gbody = body_json(app.clone().oneshot(greq).await.unwrap()).await;
694+
assert!(gbody["data"]["command_override"].is_null());
695+
assert!(gbody["data"]["env_override"].as_array().unwrap().is_empty());
696+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- Internal Aion CLI is hosted in-process and must not inherit self-repair
2+
-- executable overrides that are intended for external CLI rows.
3+
UPDATE agent_metadata
4+
SET command = NULL,
5+
command_override = NULL,
6+
env_override = NULL,
7+
updated_at = CAST(strftime('%s', 'now') AS INTEGER) * 1000
8+
WHERE agent_type = 'aionrs'
9+
AND agent_source = 'internal'
10+
AND (command IS NOT NULL OR command_override IS NOT NULL OR env_override IS NOT NULL);

crates/aionui-db/tests/cron_assistant_first_migration.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,79 @@ async fn migration_015_populates_aionrs_catalog_by_agent_type() {
194194
assert!(config_options.contains("\"yolo\""));
195195
}
196196

197+
#[tokio::test]
198+
async fn migration_016_clears_internal_aion_cli_overrides_only() {
199+
let pool = SqlitePoolOptions::new()
200+
.max_connections(1)
201+
.connect("sqlite::memory:")
202+
.await
203+
.unwrap();
204+
205+
run_migrations_through(&pool, 15).await;
206+
sqlx::query(
207+
"UPDATE agent_metadata
208+
SET command = 'bad-command',
209+
command_override = 'bad-override',
210+
env_override = '[{\"name\":\"ANTHROPIC_API_KEY\",\"value\":\"sk-x\"}]'
211+
WHERE agent_type = 'aionrs'
212+
AND agent_source = 'internal'",
213+
)
214+
.execute(&pool)
215+
.await
216+
.unwrap();
217+
218+
sqlx::query(
219+
"INSERT INTO agent_metadata (
220+
id, name, backend, command, command_override, env_override, agent_type,
221+
enabled, agent_source, sort_order, created_at, updated_at
222+
) VALUES (
223+
'external-override-agent', 'External Override', 'external', 'external-cli',
224+
'/opt/bin/external-cli', '[{\"name\":\"ANTHROPIC_API_KEY\",\"value\":\"sk-y\"}]',
225+
'acp', 1, 'builtin', 999, 1, 1
226+
)",
227+
)
228+
.execute(&pool)
229+
.await
230+
.unwrap();
231+
232+
run_migration(&pool, 16).await;
233+
234+
let internal_row = sqlx::query(
235+
"SELECT command, command_override, env_override
236+
FROM agent_metadata
237+
WHERE agent_type = 'aionrs'
238+
AND agent_source = 'internal'
239+
LIMIT 1",
240+
)
241+
.fetch_one(&pool)
242+
.await
243+
.unwrap();
244+
let internal_command: Option<String> = internal_row.get("command");
245+
let internal_command_override: Option<String> = internal_row.get("command_override");
246+
let internal_env_override: Option<String> = internal_row.get("env_override");
247+
assert_eq!(internal_command, None);
248+
assert_eq!(internal_command_override, None);
249+
assert_eq!(internal_env_override, None);
250+
251+
let external_row = sqlx::query(
252+
"SELECT command, command_override, env_override
253+
FROM agent_metadata
254+
WHERE id = 'external-override-agent'",
255+
)
256+
.fetch_one(&pool)
257+
.await
258+
.unwrap();
259+
let external_command: String = external_row.get("command");
260+
let external_command_override: String = external_row.get("command_override");
261+
let external_env_override: String = external_row.get("env_override");
262+
assert_eq!(external_command, "external-cli");
263+
assert_eq!(external_command_override, "/opt/bin/external-cli");
264+
assert_eq!(
265+
external_env_override,
266+
r#"[{"name":"ANTHROPIC_API_KEY","value":"sk-y"}]"#
267+
);
268+
}
269+
197270
async fn insert_legacy_cron(
198271
pool: &sqlx::SqlitePool,
199272
id: &str,

0 commit comments

Comments
 (0)