Skip to content

Commit e60b4e5

Browse files
authored
refactor(codex): unify top-level allow-list, fixing TOML vs JSON/YAML drift [skip changelog] (#968)
Closes #966. The Codex config top-level allow-list was maintained twice: `KNOWN_TOP_LEVEL_KEYS` + `KNOWN_TABLE_KEYS` in schemas/codex.rs (TOML backend) and `KNOWN_CONFIG_TOP_LEVEL_KEYS` in rules/codex.rs (JSON/YAML backend). They had already drifted, so the same key was accepted by one backend and flagged by the other: - `debug` (v0.129 `[debug]` table) and `include_apply_patch_tool`: valid as TOML, false-positive `CDX-CFG-006` as JSON/YAML. - `js_repl_node_path` / `js_repl_node_module_dirs`: valid as JSON/YAML, false-positive `CDX-004` as TOML. Fix: one source of truth. `schemas/codex.rs::is_known_top_level_key` (over `KNOWN_TOP_LEVEL_KEYS` ∪ `KNOWN_TABLE_KEYS`) is now consulted by both `detect_unknown_keys` (TOML) and `collect_unknown_codex_keys` (JSON/YAML). Deleted the duplicate `KNOWN_CONFIG_TOP_LEVEL_KEYS`; added the two js_repl scalars to the schemas list. Lenient union (102 keys) - strictly fewer false positives, both backends now agree. Incidentally removes the duplication gemini-code-assist flagged on #964. Nested allow-lists (features/tui/mcp_servers/shell_env/permissions/apps) already live only on the rules side and are untouched. Regression tests: `test_codex_unified_allowlist_fixes_json_toml_drift` (JSON accepts `debug`) and `test_codex_unified_allowlist_fixes_toml_drift` (TOML accepts `js_repl_node_path`).
1 parent a1c1d42 commit e60b4e5

2 files changed

Lines changed: 73 additions & 122 deletions

File tree

crates/agnix-core/src/rules/codex.rs

Lines changed: 34 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -132,115 +132,6 @@ const KNOWN_PERMISSIONS_NETWORK_KEYS: &[&str] = &[
132132

133133
const VALID_WINDOWS_SANDBOX_VALUES: &[&str] = &["elevated", "unelevated"];
134134

135-
// KNOWN_CONFIG_TOP_LEVEL_KEYS is the parallel allow-list used for .codex/config.{json,yaml}
136-
// unknown-top-level-key detection. Keep it in sync with KNOWN_TOP_LEVEL_KEYS ∪ KNOWN_TABLE_KEYS
137-
// in schemas/codex.rs (this set represents the flat union - the JSON/YAML backends don't
138-
// distinguish scalar top-level keys from TOML `[section]` tables).
139-
// Sourced from upstream codex-rs/core/config.schema.json; last reconciled @ rust-v0.133.0. Alphabetized.
140-
const KNOWN_CONFIG_TOP_LEVEL_KEYS: &[&str] = &[
141-
"agents",
142-
"allow_login_shell",
143-
"analytics",
144-
"approval_policy",
145-
"approvals_reviewer",
146-
"apps",
147-
"apps_mcp_product_sku",
148-
"audio",
149-
"auto_review",
150-
"background_terminal_max_timeout",
151-
"chatgpt_base_url",
152-
"check_for_update_on_startup",
153-
"cli_auth_credentials_store",
154-
"commit_attribution",
155-
"compact_prompt",
156-
"default_permissions",
157-
"desktop",
158-
"developer_instructions",
159-
"disable_paste_burst",
160-
"experimental_compact_prompt_file",
161-
"experimental_realtime_start_instructions",
162-
"experimental_realtime_ws_backend_prompt",
163-
"experimental_realtime_ws_base_url",
164-
"experimental_realtime_ws_model",
165-
"experimental_realtime_ws_startup_context",
166-
"experimental_thread_config_endpoint",
167-
"experimental_thread_store",
168-
"experimental_thread_store_endpoint",
169-
"experimental_use_freeform_apply_patch",
170-
"experimental_use_unified_exec_tool",
171-
"features",
172-
"feedback",
173-
"file_opener",
174-
"forced_chatgpt_workspace_id",
175-
"forced_login_method",
176-
"ghost_snapshot",
177-
"hide_agent_reasoning",
178-
"history",
179-
"hooks",
180-
"include_apps_instructions",
181-
"include_collaboration_mode_instructions",
182-
"include_environment_context",
183-
"include_permissions_instructions",
184-
"instructions",
185-
"js_repl_node_module_dirs",
186-
"js_repl_node_path",
187-
"log_dir",
188-
"marketplaces",
189-
"mcp_oauth_callback_port",
190-
"mcp_oauth_callback_url",
191-
"mcp_oauth_credentials_store",
192-
"mcp_servers",
193-
"memories",
194-
"model",
195-
"model_auto_compact_token_limit",
196-
"model_auto_compact_token_limit_scope",
197-
"model_catalog_json",
198-
"model_context_window",
199-
"model_instructions_file",
200-
"model_provider",
201-
"model_providers",
202-
"model_reasoning_effort",
203-
"model_reasoning_summary",
204-
"model_supports_reasoning_summaries",
205-
"model_verbosity",
206-
"notice",
207-
"notify",
208-
"openai_base_url",
209-
"oss_provider",
210-
"otel",
211-
"permissions",
212-
"personality",
213-
"plan_mode_reasoning_effort",
214-
"plugins",
215-
"profile",
216-
"profiles",
217-
"project_doc_fallback_filenames",
218-
"project_doc_max_bytes",
219-
"project_root_markers",
220-
"projects",
221-
"realtime",
222-
"review_model",
223-
"sandbox_mode",
224-
"sandbox_workspace_write",
225-
"service_tier",
226-
"shell_environment_policy",
227-
"show_raw_agent_reasoning",
228-
"skills",
229-
"sqlite_home",
230-
"suppress_unstable_features_warning",
231-
"tool_output_token_limit",
232-
"tool_suggest",
233-
"tools",
234-
"tui",
235-
"web_search",
236-
"windows",
237-
"windows_wsl_setup_acknowledged",
238-
"zsh_path",
239-
// Legacy compatibility in existing fixtures/tests.
240-
"approvalMode",
241-
"fullAutoErrorMode",
242-
];
243-
244135
const KNOWN_FEATURE_KEYS: &[&str] = &[
245136
"apply_patch_freeform",
246137
"apps",
@@ -2062,7 +1953,9 @@ fn collect_unknown_codex_keys(root: &Value, cdx_cfg_028_active: bool) -> Vec<Str
20621953
};
20631954

20641955
for key in root_obj.keys() {
2065-
if !KNOWN_CONFIG_TOP_LEVEL_KEYS.contains(&key.as_str()) {
1956+
// Single source of truth lives in schemas/codex.rs so the TOML and
1957+
// JSON/YAML backends can never drift on the top-level allow-list.
1958+
if !crate::schemas::codex::is_known_top_level_key(key.as_str()) {
20661959
unknown.push(key.clone());
20671960
}
20681961
}
@@ -3403,10 +3296,11 @@ experimental_thread_store_endpoint = "https://thread-store.example"
34033296
fn test_codex_v0_123_top_level_keys_accepted_json() {
34043297
// JSON/YAML path: CDX-CFG-006 fires for unknown top-level keys when
34053298
// the file is JSON/YAML (NOT TOML, where CDX-004 takes over via
3406-
// skip_top_level). The fix in rules/codex.rs::KNOWN_CONFIG_TOP_LEVEL_KEYS
3407-
// must include `experimental_thread_store_endpoint` for CDX-CFG-006
3408-
// to accept it. Without that arm of the fix, this test fails even if
3409-
// the TOML schema allow-list (schemas/codex.rs) is updated.
3299+
// skip_top_level). The shared allow-list in
3300+
// schemas/codex.rs::is_known_top_level_key must include
3301+
// `experimental_thread_store_endpoint` for CDX-CFG-006 to accept it.
3302+
// Both backends consult the same predicate, so TOML and JSON/YAML
3303+
// can no longer disagree about it.
34103304
let json = r#"{
34113305
"experimental_thread_store_endpoint": "https://thread-store.example"
34123306
}"#;
@@ -3750,11 +3644,11 @@ hide_full_access_warning = true
37503644

37513645
#[test]
37523646
fn test_codex_0_128_0_new_json_yaml_keys_not_flagged() {
3753-
// Regression guard for the JSON/YAML unknown-top-level-key path
3754-
// (KNOWN_CONFIG_TOP_LEVEL_KEYS). Mirror of the TOML-side tests in
3755-
// schemas/codex.rs; the two allow-lists are deliberately separate
3756-
// because the JSON/YAML path treats top-level scalars and objects
3757-
// uniformly while the TOML path splits scalar vs `[table]`.
3647+
// Regression guard for the JSON/YAML unknown-top-level-key path,
3648+
// which now consults schemas/codex.rs::is_known_top_level_key (the
3649+
// shared predicate over KNOWN_TOP_LEVEL_KEYS ∪ KNOWN_TABLE_KEYS).
3650+
// Mirror of the TOML-side tests in schemas/codex.rs; the JSON/YAML
3651+
// path treats top-level scalars and `[table]` keys uniformly.
37583652
let json = r#"{
37593653
"auto_review": {},
37603654
"experimental_thread_store": {},
@@ -3783,6 +3677,27 @@ hide_full_access_warning = true
37833677
);
37843678
}
37853679

3680+
#[test]
3681+
fn test_codex_unified_allowlist_fixes_json_toml_drift() {
3682+
// Drift fix (issue #966): before unifying the top-level allow-list, the
3683+
// JSON/YAML path flagged `debug` (a valid TOML `[debug]` table from the
3684+
// v0.129 catch-up) because it lived only in the TOML schema list.
3685+
// Both backends now share schemas/codex.rs::is_known_top_level_key, so
3686+
// `debug` is accepted as JSON/YAML too.
3687+
let json = r#"{ "debug": {}, "include_apply_patch_tool": true }"#;
3688+
let diags = validate_config_at_path(".codex/config.json", json);
3689+
assert!(
3690+
diags
3691+
.iter()
3692+
.all(|d| d.rule != "CDX-004" && d.rule != "CDX-CFG-006"),
3693+
"unified keys should not be flagged on the JSON path, got: {:?}",
3694+
diags
3695+
.iter()
3696+
.filter(|d| d.rule == "CDX-004" || d.rule == "CDX-CFG-006")
3697+
.collect::<Vec<_>>()
3698+
);
3699+
}
3700+
37863701
#[test]
37873702
fn test_cdx_000_reports_json_yaml_parse_errors() {
37883703
let invalid_json = r#"{"approval_policy":"always""#;

crates/agnix-core/src/schemas/codex.rs

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ pub const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
132132
"apps_mcp_product_sku",
133133
"include_collaboration_mode_instructions",
134134
"model_auto_compact_token_limit_scope",
135+
// Unified from the former rules/codex.rs JSON/YAML allow-list (js_repl
136+
// node-resolution scalars) so the TOML and JSON/YAML backends agree.
137+
"js_repl_node_module_dirs",
138+
"js_repl_node_path",
135139
// Legacy camelCase keys (backwards compat)
136140
"approvalMode",
137141
"fullAutoErrorMode",
@@ -184,6 +188,18 @@ pub const KNOWN_TABLE_KEYS: &[&str] = &[
184188
"desktop",
185189
];
186190

191+
/// Single source of truth for whether a Codex config top-level key is known.
192+
///
193+
/// Both backends consult this: the TOML path (`detect_unknown_keys` here) and
194+
/// the JSON/YAML path (`rules::codex::collect_unknown_codex_keys`). Keeping one
195+
/// predicate prevents the two from drifting (a key valid as TOML but flagged as
196+
/// JSON, or vice versa). A top-level key may be either a scalar
197+
/// (`KNOWN_TOP_LEVEL_KEYS`) or a `[section]` table (`KNOWN_TABLE_KEYS`).
198+
#[must_use]
199+
pub fn is_known_top_level_key(key: &str) -> bool {
200+
KNOWN_TOP_LEVEL_KEYS.contains(&key) || KNOWN_TABLE_KEYS.contains(&key)
201+
}
202+
187203
/// An unknown key found in config
188204
#[derive(Debug, Clone)]
189205
pub struct UnknownKey {
@@ -396,9 +412,7 @@ fn detect_unknown_keys(
396412
// and this avoids HashSet allocation on every call.
397413
let mut unknown = Vec::new();
398414
for key in table.keys() {
399-
if !KNOWN_TOP_LEVEL_KEYS.contains(&key.as_str())
400-
&& !KNOWN_TABLE_KEYS.contains(&key.as_str())
401-
{
415+
if !is_known_top_level_key(key.as_str()) {
402416
unknown.push(UnknownKey {
403417
key: key.clone(),
404418
line: find_toml_key_line(content, key).unwrap_or(1),
@@ -741,6 +755,28 @@ nested_number = 42
741755
);
742756
}
743757

758+
#[test]
759+
fn test_codex_unified_allowlist_fixes_toml_drift() {
760+
// Drift fix (issue #966): before unifying the top-level allow-list, the
761+
// TOML path flagged `js_repl_node_path` / `js_repl_node_module_dirs`
762+
// because they lived only in the JSON/YAML list on the rules side.
763+
// They are now part of KNOWN_TOP_LEVEL_KEYS, so the TOML path accepts
764+
// them too.
765+
let content =
766+
"js_repl_node_path = \"/usr/bin/node\"\njs_repl_node_module_dirs = [\"/x\"]\n";
767+
let result = parse_codex_toml(content);
768+
assert!(result.parse_error.is_none());
769+
assert!(
770+
result.unknown_keys.is_empty(),
771+
"js_repl_* keys should not be flagged on the TOML path, got: {:?}",
772+
result
773+
.unknown_keys
774+
.iter()
775+
.map(|u| u.key.as_str())
776+
.collect::<Vec<_>>()
777+
);
778+
}
779+
744780
#[test]
745781
fn test_unknown_keys_empty_on_parse_error() {
746782
let content = "invalid = [unclosed";

0 commit comments

Comments
 (0)