From f2bc24c102e8e069a826a9db0f2970dafda61716 Mon Sep 17 00:00:00 2001 From: "Dr. Fabian Wilde" Date: Tue, 13 Oct 2026 02:32:10 +0200 Subject: [PATCH] fix(gate): trust interactive Codex session writes Recognize non-empty CODEX_THREAD_ID and CODEX_SESSION_ID as interactive CLI callers after hook and harness checks. Infer memory source provenance from the caller markers while preserving explicit LORE_ENGINE overrides. Cover memory add, replace, remove, config writes, hook precedence, empty markers, and the MCP model gate. Release 0.62.22 updates both plugin manifests, the marketplace, and native package metadata. --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 8 ++ Cargo.lock | 2 +- docs/write-gate.md | 14 +++ lore_core/gate.py | 19 ++-- plugin.json | 2 +- rust/lore-core/Cargo.toml | 2 +- rust/lore-core/src/standalone.rs | 17 +++- rust/lore-core/tests/cli.rs | 132 +++++++++++++++++++++++++++ rust/lore-core/tests/native_admin.rs | 4 + tests/test_write_gate.py | 36 ++++++++ 12 files changed, 228 insertions(+), 12 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 04ff28d..f41f7f2 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "lore", "source": "./", "description": "Shared memory for Claude Code, Codex, and DOXA: capped curated facts, evidence-backed beliefs, FTS5 session search, staged review, learned skills, and signed sync.", - "version": "0.62.21" + "version": "0.62.22" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1ae8de6..a3f06d9 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "lore", "description": "Memory for Claude Code that reasons about you and improves itself: Honcho-pattern belief store with deriver, dreamer and dialectic (/lore:ask), automatic skillification with a closed judge/update/retire loop, on a Hermes-pattern core \u2014 hard-capped curated memory, FTS5 session search, staged review.", - "version": "0.62.21", + "version": "0.62.22", "author": { "name": "Fabian Wilde", "email": "docwilde@proton.me" diff --git a/CHANGELOG.md b/CHANGELOG.md index 956a0af..4bdebde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.62.22 — 2026-10-13 + +**Codex memory writes** + +- Fix **`standalone::authority`** and **`detect_writer`** ignoring Codex session markers; interactive memory writes now apply instead of staging or receiving `untrusted_write`. +- Infer **`source_engine`** from Codex or Claude caller markers when **`LORE_ENGINE`** is unset; explicit overrides still win. +- Retain hook staging, detached-write gating, and MCP proposal review; empty session markers and engine labels grant no write authority. + ## 0.62.21 — 2026-10-12 **Reviewed Python codegraph snapshots** diff --git a/Cargo.lock b/Cargo.lock index 14813d5..75c0ca7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -612,7 +612,7 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lore-core" -version = "0.62.21" +version = "0.62.22" dependencies = [ "fancy-regex", "hmac", diff --git a/docs/write-gate.md b/docs/write-gate.md index 871aa96..864f210 100644 --- a/docs/write-gate.md +++ b/docs/write-gate.md @@ -40,6 +40,20 @@ environment, stdin a tty — a human in a shell) write directly. `hook` and sets neither marker fails *open* to `interactive`, rather than staging every routine write on an install this can't measure. +Codex shell tool calls carry `CODEX_THREAD_ID` and `CODEX_SESSION_ID`, as +observed in the Codex desktop session that reproduced the missing +classification. Either non-empty marker classifies the caller as +`interactive`, even when stdin is a pipe or `/dev/null`. Harness markers +and the Claude hook check above take precedence over inherited Codex +markers. Empty session markers and `LORE_ENGINE=codex` alone do not grant +write authority; `LORE_ENGINE` only labels provenance. The native carrier +infers the source engine from the caller markers when no explicit +`LORE_ENGINE` override is present. + +This recognition applies to the standalone CLI and public bridge. The MCP +and agent bridge retain their model proposal path, and Codex CLI writes +remain `interactive`, distinct from explicit human review operations. + ## The gate is advisory Every signal lives in the caller's own environment, and a hook runs as the diff --git a/lore_core/gate.py b/lore_core/gate.py index 4368252..d134cfd 100644 --- a/lore_core/gate.py +++ b/lore_core/gate.py @@ -13,14 +13,14 @@ WHAT THIS DOES. Every CLI write is classified by WHO is calling: - interactive a Claude Code TOOL subprocess -- the agent running `lore - memory add` through Bash. The intended path; applies. - terminal a human at a keyboard (no Claude Code in the environment, + interactive a Claude Code or Codex TOOL subprocess -- the agent running + `lore memory add` through a shell. The intended path; applies. + terminal a human at a keyboard (no coding agent in the environment, stdin is a tty). The most human-approved path there is; applies. hook a command Claude Code executed as a HOOK -- the untrusted door. Stages into pending/ instead of applying. - detached no Claude Code, no tty: cron, a daemon, a background script. + detached no coding agent, no tty: cron, a daemon, a background script. Stages. ("unknown" is not a writer class -- it is the provenance label for an entry @@ -168,6 +168,10 @@ def detect_writer() -> "tuple[str, str]": install that cannot be measured -- an honest gate is worth more than a gate that breaks the intended path. It costs nothing against an adversary who could forge the variable anyway. + + Codex shell tools carry CODEX_THREAD_ID / CODEX_SESSION_ID despite + non-terminal stdin. Either non-empty marker is interactive, after the + hook checks above. LORE_ENGINE labels origin; it never grants authority. """ ai_agent = os.environ.get("AI_AGENT", "") project_dir = os.environ.get("CLAUDE_PROJECT_DIR", "") @@ -184,9 +188,12 @@ def detect_writer() -> "tuple[str, str]": if in_claude: # Claude Code, no agent/harness marker: an older version. Fail open. return WRITER_INTERACTIVE, "CLAUDECODE set, no AI_AGENT marker (pre-2.1 shape)" + for marker in ("CODEX_THREAD_ID", "CODEX_SESSION_ID"): + if os.environ.get(marker): + return WRITER_INTERACTIVE, f"{marker} set" if _stdin_is_tty(): - return WRITER_TERMINAL, "no Claude Code in env, stdin is a tty" - return WRITER_DETACHED, "no Claude Code in env, stdin is not a tty" + return WRITER_TERMINAL, "no coding agent in env, stdin is a tty" + return WRITER_DETACHED, "no coding agent in env, stdin is not a tty" def writer_class() -> str: diff --git a/plugin.json b/plugin.json index c8d86c9..94001d8 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "lore", - "version": "0.62.21", + "version": "0.62.22", "description": "Unified user, repo, and session memory for Claude Code, Codex, and DOXA", "author": { "name": "Dr. Fabian Wilde", diff --git a/rust/lore-core/Cargo.toml b/rust/lore-core/Cargo.toml index d9fe6e4..0dbb885 100644 --- a/rust/lore-core/Cargo.toml +++ b/rust/lore-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lore-core" -version = "0.62.21" +version = "0.62.22" edition = "2021" license = "AGPL-3.0-only" description = "Native LORE memory, beliefs, review and session index" diff --git a/rust/lore-core/src/standalone.rs b/rust/lore-core/src/standalone.rs index 54f3e39..14bb3ef 100644 --- a/rust/lore-core/src/standalone.rs +++ b/rust/lore-core/src/standalone.rs @@ -35,10 +35,24 @@ Sync pulls configured sources; bootstrap requires one source (or --peer). Review uses the Claude CLI; source --engine identifies the transcript provider. "#; pub fn authority() -> Authority { - let engine = crate::config::var("LORE_ENGINE").unwrap_or_else(|_| "unknown".into()); let marker = std::env::var("AI_AGENT").unwrap_or_default(); let claude = std::env::var("CLAUDECODE").is_ok_and(|s| !s.is_empty()) || marker.starts_with("claude-code"); + // Codex shell tools have non-terminal stdin. Bind them by session markers, + // after the hook/harness check below; LORE_ENGINE alone is provenance. + let codex = ["CODEX_THREAD_ID", "CODEX_SESSION_ID"] + .iter() + .any(|key| std::env::var(key).is_ok_and(|s| !s.is_empty())); + let engine = crate::config::var("LORE_ENGINE").unwrap_or_else(|_| { + if claude { + "claude" + } else if codex { + "codex" + } else { + "unknown" + } + .into() + }); let project = std::env::var("CLAUDE_PROJECT_DIR").is_ok_and(|s| !s.is_empty()); let agent = "lore-cli".to_string(); if marker.ends_with("_harness") || claude && project && !marker.ends_with("_agent") { @@ -50,6 +64,7 @@ pub fn authority() -> Authority { } if marker.ends_with("_agent") || claude + || codex || crate::config::var("LORE_WRITE_GATE") .is_ok_and(|s| matches!(s.as_str(), "off" | "0" | "false")) { diff --git a/rust/lore-core/tests/cli.rs b/rust/lore-core/tests/cli.rs index a38fa95..5245e59 100644 --- a/rust/lore-core/tests/cli.rs +++ b/rust/lore-core/tests/cli.rs @@ -319,6 +319,138 @@ fn memory_readback_preserves_scopes_unknown_sources_and_other_host_pull_policy() assert_eq!(tree(&fixture.root), before); assert!(!fixture.root.join("state.db").exists() && !fixture.root.join(".locks").exists()); } +#[test] +fn codex_session_writes_apply_with_interactive_provenance() { + for marker in ["CODEX_THREAD_ID", "CODEX_SESSION_ID"] { + let fixture = Fixture::new(); + let env = [(marker, "fixture-codex-session")]; + let (success, out, err) = fixture.invoke_env( + &["memory", "add", "--scope", "user", "Codex session fact"], + &env, + ); + assert!(success && err.is_empty(), "{marker}: {out} {err}"); + assert_eq!( + fs::read_to_string(fixture.root.join("USER.md")).unwrap(), + "- Codex session fact\n" + ); + let cfg = config::Config::for_root(fixture.root.clone()); + let provenance = gate::provenance(&cfg, "memory", "user", "Codex session fact"); + assert_eq!(provenance["writer"], "interactive", "{marker}"); + assert_eq!(provenance["source_engine"], "codex", "{marker}"); + assert!(lore_core::pending::ids(&cfg).unwrap().is_empty()); + + let (success, out, err) = fixture.invoke_env( + &[ + "memory", "replace", "--scope", "user", "--match", + "Codex session fact", "Updated Codex fact", + ], + &env, + ); + assert!(success && err.is_empty(), "{marker}: {out} {err}"); + assert_eq!( + fs::read_to_string(fixture.root.join("USER.md")).unwrap(), + "- Updated Codex fact\n" + ); + let (success, out, err) = fixture.invoke_env( + &[ + "memory", "remove", "--scope", "user", "--match", "Updated Codex fact", + ], + &env, + ); + assert!(success && err.is_empty(), "{marker}: {out} {err}"); + assert_eq!( + fs::read_to_string(fixture.root.join("USER.md")).unwrap(), + "" + ); + assert!(lore_core::pending::ids(&cfg).unwrap().is_empty()); + } +} + +#[test] +fn codex_session_can_write_config_and_preserves_explicit_source_engine() { + let fixture = Fixture::new(); + let env = [ + ("CODEX_THREAD_ID", "fixture-codex-session"), + ("LORE_ENGINE", "custom"), + ]; + let (success, out, err) = + fixture.invoke_env(&["config", "set", "LORE_REFRESH_SECS", "60"], &env); + assert!(success && err.is_empty(), "{out} {err}"); + let settings: serde_json::Value = serde_json::from_slice( + &fs::read(fixture.home.join(".claude/settings.json")).unwrap(), + ).unwrap(); + assert_eq!(settings["env"]["LORE_REFRESH_SECS"], "60"); + let (success, out, err) = fixture.invoke_env( + &["memory", "add", "--scope", "user", "Explicit engine fact"], + &env, + ); + assert!(success && err.is_empty(), "{out} {err}"); + let cfg = config::Config::for_root(fixture.root.clone()); + assert_eq!( + gate::provenance(&cfg, "memory", "user", "Explicit engine fact")["source_engine"], + "custom" + ); +} + +#[test] +fn codex_markers_do_not_override_hooks_or_grant_human_review() { + for extra in [ + vec![("AI_AGENT", "codex_fixture_harness")], + vec![ + ("AI_AGENT", "claude-code_fixture_harness"), + ("CLAUDECODE", "1"), + ], + vec![("CLAUDECODE", "1"), ("CLAUDE_PROJECT_DIR", "/fixture")], + ] { + let fixture = Fixture::new(); + let mut env = vec![ + ("CODEX_THREAD_ID", "fixture-codex-session"), + ("CODEX_SESSION_ID", "fixture-codex-session"), + ]; + env.extend(extra); + let (success, out, err) = fixture.invoke_env( + &["memory", "add", "--scope", "user", "Hook proposal"], + &env, + ); + assert!(success && err.is_empty(), "{out} {err}"); + assert_eq!( + serde_json::from_str::(&out).unwrap()["status"], + "staged" + ); + assert!(!fixture.root.join("USER.md").exists()); + let (success, out, err) = + fixture.invoke_env(&["config", "set", "LORE_REFRESH_SECS", "60"], &env); + assert!(!success && out.is_empty()); + assert_eq!(err, "lore-rs: untrusted_write\n"); + } + let fixture = Fixture::new(); + let (success, out, err) = fixture.invoke_env( + &["codegraph", "store"], + &[("CODEX_THREAD_ID", "fixture-codex-session")], + ); + assert!(!success && out.is_empty()); + assert_eq!(err, "lore-rs: untrusted_write\n"); +} + +#[test] +fn empty_codex_markers_and_engine_labels_do_not_grant_write_authority() { + let fixture = Fixture::new(); + let (success, out, err) = fixture.invoke_env( + &["memory", "add", "--scope", "user", "Detached proposal"], + &[ + ("CODEX_THREAD_ID", ""), + ("CODEX_SESSION_ID", ""), + ("LORE_ENGINE", "codex"), + ], + ); + assert!(success && err.is_empty(), "{out} {err}"); + assert_eq!( + serde_json::from_str::(&out).unwrap()["status"], + "staged" + ); + assert!(!fixture.root.join("USER.md").exists()); +} + #[test] fn project_and_filemap_use_current_repository_identity_without_mutating_files() { let mut fixture = Fixture::new(); diff --git a/rust/lore-core/tests/native_admin.rs b/rust/lore-core/tests/native_admin.rs index ddb9ad5..fae2db7 100644 --- a/rust/lore-core/tests/native_admin.rs +++ b/rust/lore-core/tests/native_admin.rs @@ -191,6 +191,9 @@ fn native_cli_respects_detached_write_gate_and_mcp_model_authority() { .env("LORE_ROOT", &cfg.root) .env_remove("AI_AGENT") .env_remove("CLAUDECODE") + .env_remove("CLAUDE_PROJECT_DIR") + .env_remove("CODEX_THREAD_ID") + .env_remove("CODEX_SESSION_ID") .env_remove("LORE_WRITE_GATE") .stdin(std::process::Stdio::null()) .output() @@ -217,6 +220,7 @@ fn native_cli_respects_detached_write_gate_and_mcp_model_authority() { ]) .env("LORE_ROOT", &cfg.root) .env("LORE_WRITE_GATE", "off") + .env("CODEX_THREAD_ID", "fixture-codex-session") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .spawn() diff --git a/tests/test_write_gate.py b/tests/test_write_gate.py index e624477..5e7e757 100644 --- a/tests/test_write_gate.py +++ b/tests/test_write_gate.py @@ -64,6 +64,7 @@ @contextlib.contextmanager def as_writer(env: dict, drop=("AI_AGENT", "CLAUDECODE", "CLAUDE_PROJECT_DIR", + "CODEX_THREAD_ID", "CODEX_SESSION_ID", "LORE_ENGINE", "LORE_WRITE_GATE")): with mock.patch.dict(os.environ, {}, clear=False): for k in drop: @@ -124,6 +125,24 @@ def test_agent_marker_is_interactive(self): with as_writer(AGENT_ENV): self.assertEqual(lore.writer_class(), lore.WRITER_INTERACTIVE) + def test_codex_session_markers_are_interactive(self): + for marker in ("CODEX_THREAD_ID", "CODEX_SESSION_ID"): + with self.subTest(marker=marker), as_writer({marker: "fixture-codex-session"}): + self.assertEqual(lore.writer_class(), lore.WRITER_INTERACTIVE) + self.assertEqual(lore.current_engine(), "codex") + + def test_codex_markers_do_not_override_hook_classification(self): + for hook in (HOOK_ENV, {"AI_AGENT": "codex_fixture_harness"}, + {"CLAUDECODE": "1", "CLAUDE_PROJECT_DIR": CWD}): + with self.subTest(hook=hook), as_writer(hook | {"CODEX_THREAD_ID": "fixture-codex-session"}): + self.assertEqual(lore.writer_class(), lore.WRITER_HOOK) + + def test_empty_codex_markers_and_engine_label_are_detached(self): + with as_writer({"CODEX_THREAD_ID": "", "CODEX_SESSION_ID": "", "LORE_ENGINE": "codex"}), \ + mock.patch("lore_core.gate.sys.stdin") as stdin: + stdin.isatty.return_value = False + self.assertEqual(lore.writer_class(), lore.WRITER_DETACHED) + def test_harness_marker_is_hook(self): with as_writer(HOOK_ENV): self.assertEqual(lore.writer_class(), lore.WRITER_HOOK) @@ -204,6 +223,23 @@ def test_interactive_write_applies_immediately(self): self.assertEqual(_entries(), ["the agent's own durable fact"]) self.assertEqual(_pending_items(), []) + def test_codex_add_replace_and_remove_apply_immediately(self): + for marker in ("CODEX_THREAD_ID", "CODEX_SESSION_ID"): + with self.subTest(marker=marker), as_writer({marker: "fixture-codex-session"}), quiet(): + rc = lore.cmd_memory(Namespace(mcmd="add", scope="project", cwd=CWD, + text=["Codex durable fact"])) + self.assertEqual(rc, 0) + self.assertEqual(_entries(), ["Codex durable fact"]) + rc = lore.cmd_memory(Namespace(mcmd="replace", scope="project", cwd=CWD, + match="Codex durable fact", text=["Updated Codex fact"])) + self.assertEqual(rc, 0) + self.assertEqual(_entries(), ["Updated Codex fact"]) + rc = lore.cmd_memory(Namespace(mcmd="remove", scope="project", cwd=CWD, + match="Updated Codex fact")) + self.assertEqual(rc, 0) + self.assertEqual(_entries(), []) + self.assertEqual(_pending_items(), []) + def test_hook_replace_and_remove_cannot_mutate_the_store(self): with as_writer(AGENT_ENV), quiet(): lore.cmd_memory(Namespace(mcmd="add", scope="project", cwd=CWD,