Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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**
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions docs/write-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 13 additions & 6 deletions lore_core/gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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", "")
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion rust/lore-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
17 changes: 16 additions & 1 deletion rust/lore-core/src/standalone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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"))
{
Expand Down
132 changes: 132 additions & 0 deletions rust/lore-core/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<serde_json::Value>(&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::<serde_json::Value>(&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();
Expand Down
4 changes: 4 additions & 0 deletions rust/lore-core/tests/native_admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down
36 changes: 36 additions & 0 deletions tests/test_write_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Loading