From 0219994a8d7e318ecd0bdf1ad811e7c649bc911d Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Fri, 12 Jun 2026 08:53:27 +0400 Subject: [PATCH 1/2] fix(classifier): actually set the recursion marker on spawned claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ingest-hook recursion guard checked TJ_IN_CLASSIFIER, but no spawn site ever set it — so every classifier `claude -p` (a full Claude Code instance) re-ran the user's SessionStart hooks, including ingest-hook, which spawned another classifier `claude -p`… an unbounded fork bomb. On a machine with git-touching SessionStart hooks (or aimux) this also hammered git (dozens of concurrent `git pull origin HEAD`, stray commits to main). - Both runners now set TJ_IN_CLASSIFIER=1 via a shared base_claude_command. - Guard + worker env_remove now reference one tj_core constant (IN_CLASSIFIER_ENV) so the setter and checker can't drift again. - Regression test asserts the spawned command carries the marker — the missing wiring that had no test is exactly what let the bomb through. cargo test -p task-journal-core agent_sdk: 10/10; cli ingest guard green. --- crates/tj-cli/src/main.rs | 4 +- crates/tj-core/src/classifier/agent_sdk.rs | 61 +++++++++++++++++----- 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 53889b1..83c51cf 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -1629,7 +1629,7 @@ fn main() -> Result<()> { // subscription auth (claude-memory-0kk), so the classifier // now sets TJ_IN_CLASSIFIER=1 in the child env and we bail // here when we see it. - if std::env::var("TJ_IN_CLASSIFIER").is_ok() { + if std::env::var(tj_core::classifier::agent_sdk::IN_CLASSIFIER_ENV).is_ok() { return Ok(()); } @@ -3734,7 +3734,7 @@ fn spawn_classify_worker(backend: &str) -> anyhow::Result<()> { .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .env("TJ_CLASSIFIER_BUMP", "1") - .env_remove("TJ_IN_CLASSIFIER"); + .env_remove(tj_core::classifier::agent_sdk::IN_CLASSIFIER_ENV); let _child = cmd.spawn().context("spawn classify-worker")?; // Drop child intentionally — Linux init reaps when parent exits. Ok(()) diff --git a/crates/tj-core/src/classifier/agent_sdk.rs b/crates/tj-core/src/classifier/agent_sdk.rs index 9b5ad73..2ea41ce 100644 --- a/crates/tj-core/src/classifier/agent_sdk.rs +++ b/crates/tj-core/src/classifier/agent_sdk.rs @@ -21,6 +21,17 @@ use std::process::Command; /// `TJ_AGENT_SDK_MODEL`. pub const DEFAULT_MODEL: &str = "claude-haiku-4-5"; +/// Env var stamped onto every spawned classifier `claude -p` subprocess. That +/// subprocess is a full Claude Code instance, so on startup it re-runs the +/// user's SessionStart hooks — including `task-journal ingest-hook`, which +/// would spawn yet another classifier `claude -p`, and so on: an unbounded +/// fork bomb. `ingest-hook` checks for this marker and no-ops when it is set, +/// breaking the recursion. The CLI guard and the worker's `env_remove` both +/// reference this constant so the setter and the checker can never drift +/// (which is exactly the bug that let the fork bomb through: the guard checked +/// `TJ_IN_CLASSIFIER` but no spawn site ever set it). +pub const IN_CLASSIFIER_ENV: &str = "TJ_IN_CLASSIFIER"; + /// "Run the classifier command and hand back its raw stdout." The production /// impl shells out to `claude`; tests inject a fake returning canned JSON. pub trait CommandRunner: Send + Sync { @@ -29,6 +40,24 @@ pub trait CommandRunner: Send + Sync { fn run(&self, model: &str, prompt: &str) -> anyhow::Result; } +/// Build the base `claude` invocation shared by both runners: print mode, the +/// pinned model, the JSON envelope, an isolated MCP config, and — critically — +/// the [`IN_CLASSIFIER_ENV`] recursion marker. The argv runner appends the +/// prompt as a positional arg; the stdin runner feeds it on stdin. Extracted so +/// a unit test can assert the marker is present without spawning `claude` (the +/// missing marker is exactly what let the fork bomb through before). +fn base_claude_command(model: &str) -> Command { + let mut cmd = Command::new("claude"); + cmd.arg("-p") + .arg("--model") + .arg(model) + .arg("--output-format") + .arg("json") + .arg("--strict-mcp-config") + .env(IN_CLASSIFIER_ENV, "1"); + cmd +} + /// Production runner: invokes the local `claude` binary in print mode, pinned /// to the given model, asking for the JSON envelope and an isolated MCP config /// (`--strict-mcp-config` keeps the project's own MCP servers — including this @@ -37,14 +66,8 @@ pub struct ClaudeBinaryRunner; impl CommandRunner for ClaudeBinaryRunner { fn run(&self, model: &str, prompt: &str) -> anyhow::Result { - let output = Command::new("claude") - .arg("-p") + let output = base_claude_command(model) .arg(prompt) - .arg("--model") - .arg(model) - .arg("--output-format") - .arg("json") - .arg("--strict-mcp-config") .output() .context("failed to spawn `claude` (is Claude Code installed and on PATH?)")?; if !output.status.success() { @@ -70,13 +93,7 @@ impl CommandRunner for ClaudeBinaryStdinRunner { fn run(&self, model: &str, prompt: &str) -> anyhow::Result { use std::io::Write; use std::process::Stdio; - let mut child = Command::new("claude") - .arg("-p") - .arg("--model") - .arg(model) - .arg("--output-format") - .arg("json") - .arg("--strict-mcp-config") + let mut child = base_claude_command(model) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -240,6 +257,22 @@ mod tests { .to_string() } + #[test] + fn base_command_carries_recursion_marker() { + use std::ffi::OsStr; + // The tj-cli ingest-hook guard short-circuits on this exact var; if the + // const and the spawn site ever drift, the fork bomb returns. + assert_eq!(IN_CLASSIFIER_ENV, "TJ_IN_CLASSIFIER"); + let cmd = base_claude_command("claude-haiku-4-5"); + let marker = cmd + .get_envs() + .any(|(k, v)| k == OsStr::new(IN_CLASSIFIER_ENV) && v == Some(OsStr::new("1"))); + assert!( + marker, + "every spawned `claude -p` must set {IN_CLASSIFIER_ENV}=1 to break ingest-hook recursion" + ); + } + #[test] fn parses_canned_verdict_into_classify_output() { let verdict = r#"{"event_type":"decision","task_id_guess":"tj-x","confidence":0.93,"evidence_strength":null,"suggested_text":"Adopt Rust."}"#; From 4a6796560bc4718e593e57df09c4593f716f4c7d Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Fri, 12 Jun 2026 08:56:21 +0400 Subject: [PATCH 2/2] =?UTF-8?q?chore(release):=200.13.1=20=E2=80=94=20clas?= =?UTF-8?q?sifier=20recursion=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude-plugin/marketplace.json | 4 ++-- CHANGELOG.md | 15 +++++++++++++++ Cargo.lock | 6 +++--- Cargo.toml | 2 +- crates/tj-cli/Cargo.toml | 2 +- crates/tj-mcp/Cargo.toml | 2 +- plugin/.claude-plugin/plugin.json | 2 +- 7 files changed, 24 insertions(+), 9 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a83d4bf..fbad745 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ }, "metadata": { "description": "Task Journal — append-only reasoning chain memory for AI-coding tasks", - "version": "0.13.0" + "version": "0.13.1" }, "plugins": [ { "name": "task-journal", "source": "./plugin", "description": "Append-only journal of AI-coding task reasoning chains. Captures hypotheses, decisions, rejections, evidence — renders compact resume packs so an agent can pick up a 2-week-old task with full context.", - "version": "0.13.0", + "version": "0.13.1", "author": { "name": "Digital-Threads" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index c5f027a..5eb1a11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.13.1] - 2026-06-12 + +### Fixed +- **Classifier `claude -p` recursion fork bomb.** The `ingest-hook` recursion + guard checked `TJ_IN_CLASSIFIER`, but no spawn site ever set it — so every + classifier `claude -p` (a full Claude Code instance) re-ran the user's + SessionStart hooks, including `ingest-hook`, which spawned another classifier + `claude -p`, unbounded. On machines with git-touching SessionStart hooks (or + an agent multiplexer like aimux) this also hammered git with dozens of + concurrent `git pull origin HEAD` and stray commits to `main`. Both runners + now stamp the marker via a shared `base_claude_command`; the guard and the + worker's `env_remove` reference one `tj_core` constant so the setter and + checker can't drift again; a regression test asserts the spawned command + carries the marker. + ### Added - **`dream` backfill runs on the subscription via agent-sdk (Haiku).** The offline dream Pass A backend can now reach an LLM through the local `claude` diff --git a/Cargo.lock b/Cargo.lock index 60c0116..a54750e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2166,7 +2166,7 @@ dependencies = [ [[package]] name = "task-journal-cli" -version = "0.13.0" +version = "0.13.1" dependencies = [ "anyhow", "assert_cmd", @@ -2189,7 +2189,7 @@ dependencies = [ [[package]] name = "task-journal-core" -version = "0.13.0" +version = "0.13.1" dependencies = [ "anyhow", "chrono", @@ -2213,7 +2213,7 @@ dependencies = [ [[package]] name = "task-journal-mcp" -version = "0.13.0" +version = "0.13.1" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index e8a6a0f..30d57a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.13.0" +version = "0.13.1" edition = "2021" rust-version = "1.88" license = "MIT" diff --git a/crates/tj-cli/Cargo.toml b/crates/tj-cli/Cargo.toml index c82ec95..53a44ff 100644 --- a/crates/tj-cli/Cargo.toml +++ b/crates/tj-cli/Cargo.toml @@ -16,7 +16,7 @@ name = "task-journal" path = "src/main.rs" [dependencies] -tj-core = { package = "task-journal-core", version = "0.13.0", path = "../tj-core" } +tj-core = { package = "task-journal-core", version = "0.13.1", path = "../tj-core" } anyhow = { workspace = true } clap = { workspace = true } tracing = { workspace = true } diff --git a/crates/tj-mcp/Cargo.toml b/crates/tj-mcp/Cargo.toml index 8d606db..6c88d59 100644 --- a/crates/tj-mcp/Cargo.toml +++ b/crates/tj-mcp/Cargo.toml @@ -16,7 +16,7 @@ name = "task-journal-mcp" path = "src/main.rs" [dependencies] -tj-core = { package = "task-journal-core", version = "0.13.0", path = "../tj-core" } +tj-core = { package = "task-journal-core", version = "0.13.1", path = "../tj-core" } anyhow = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 647c686..33cdcea 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "task-journal", - "version": "0.13.0", + "version": "0.13.1", "description": "Append-only journal of AI-coding task reasoning chains: hypotheses, decisions, rejections, evidence. Renders compact resume packs so an agent can pick up a 2-week-old task with full context.", "author": { "name": "Mher Shahinyan"