From 4bf1039bb712b1d8b1b3f5cd19c5c906565ae0ee Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Sat, 18 Jul 2026 22:47:05 +0300 Subject: [PATCH] fix: deny Claude subagents by default Co-Authored-By: Claude Fable 5 --- ...18-default-deny-claude-subagents-design.md | 22 ++++++++ ...026-07-18-default-deny-claude-subagents.md | 56 +++++++++++++++++++ src/brainlayer/ingest_denylist.py | 5 +- tests/test_ingest_denylist.py | 31 +++++----- tests/test_watcher_bridge.py | 12 ++-- 5 files changed, 107 insertions(+), 19 deletions(-) create mode 100644 docs/plans/2026-07-18-default-deny-claude-subagents-design.md create mode 100644 docs/plans/2026-07-18-default-deny-claude-subagents.md diff --git a/docs/plans/2026-07-18-default-deny-claude-subagents-design.md b/docs/plans/2026-07-18-default-deny-claude-subagents-design.md new file mode 100644 index 00000000..a8959025 --- /dev/null +++ b/docs/plans/2026-07-18-default-deny-claude-subagents-design.md @@ -0,0 +1,22 @@ +# Default Claude Subagent Denylist Design + +## Goal + +Make Claude subagent transcripts opt-out of ingestion by default by adding +`~/.claude/projects/**/subagents/**` to `DEFAULT_INGEST_DENYLIST`. + +## Design + +Keep the existing denylist matcher and environment-override contract unchanged. +The default tuple gains the subagent glob alongside the workflow glob, so direct +Claude sessions and other providers remain allowed. An explicitly configured +`BRAINLAYER_INGEST_DENYLIST`, including an empty value, continues to replace the +defaults. Existing attribution code remains in place for compatibility; this +patch does not refactor adjacent policy machinery. + +## Verification + +Update the focused policy test first so it expects ordinary attributed Claude +subagents to be denied. Confirm that test fails against the old default, make +the one-line tuple change, then run the complete denylist tests and the full +project gate before publishing the PR. diff --git a/docs/plans/2026-07-18-default-deny-claude-subagents.md b/docs/plans/2026-07-18-default-deny-claude-subagents.md new file mode 100644 index 00000000..a37dfeb4 --- /dev/null +++ b/docs/plans/2026-07-18-default-deny-claude-subagents.md @@ -0,0 +1,56 @@ +# Default Claude Subagent Denylist Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Deny Claude subagent transcripts by default while preserving explicit environment overrides. + +**Architecture:** Extend the existing immutable default pattern tuple rather than adding a second policy path. Verify behavior through the public `is_denylisted` API so the test covers home inference, glob expansion, and matching together. + +**Tech Stack:** Python 3.11+, pytest, pathlib-based source policy. + +--- + +### Task 1: Lock the default policy with a failing test + +**Files:** +- Modify: `tests/test_ingest_denylist.py` + +**Step 1: Change the ordinary-subagent policy test** + +Rename `test_default_policy_allows_ordinary_claude_subagents` to +`test_default_policy_excludes_ordinary_claude_subagents` and assert that both +the `Explore` and `general-purpose` transcript paths are denylisted. + +**Step 2: Run the focused test to verify RED** + +Run: `pytest tests/test_ingest_denylist.py::test_default_policy_excludes_ordinary_claude_subagents -q` + +Expected: FAIL because the old default allows both attributed workers. + +### Task 2: Extend the default tuple + +**Files:** +- Modify: `src/brainlayer/ingest_denylist.py` +- Test: `tests/test_ingest_denylist.py` + +**Step 1: Add the exact default glob** + +Add `~/.claude/projects/**/subagents/**` to `DEFAULT_INGEST_DENYLIST` without +changing override or attribution handling. + +**Step 2: Verify GREEN** + +Run: `pytest tests/test_ingest_denylist.py -q` + +Expected: all denylist tests pass, including explicit-empty override coverage. + +**Step 3: Run the repository gate** + +Run the full pytest suite with the repository's normal exclusions and the +focused non-pytest checks used by the release gate. + +**Step 4: Commit and publish** + +Commit the code, tests, and design records with the required co-author trailer; +push `fix/default-deny-claude-subagents`; open a PR and request Codex, Cursor, +Bugbot, and CodeRabbit reviews. diff --git a/src/brainlayer/ingest_denylist.py b/src/brainlayer/ingest_denylist.py index f3e31aab..373c2626 100644 --- a/src/brainlayer/ingest_denylist.py +++ b/src/brainlayer/ingest_denylist.py @@ -11,7 +11,10 @@ BRAINLAYER_INGEST_DENYLIST_ENV = "BRAINLAYER_INGEST_DENYLIST" -DEFAULT_INGEST_DENYLIST = ("~/.claude/projects/**/wf_*/**",) +DEFAULT_INGEST_DENYLIST = ( + "~/.claude/projects/**/wf_*/**", + "~/.claude/projects/**/subagents/**", +) @dataclass(frozen=True) diff --git a/tests/test_ingest_denylist.py b/tests/test_ingest_denylist.py index 6d5094bf..9462d331 100644 --- a/tests/test_ingest_denylist.py +++ b/tests/test_ingest_denylist.py @@ -39,7 +39,7 @@ def test_default_policy_allows_normal_provider_sessions(monkeypatch, tmp_path): assert not is_denylisted(backup_home / ".claude" / "projects" / "proj" / "direct-session.jsonl") -def test_default_policy_allows_ordinary_claude_subagents(monkeypatch, tmp_path): +def test_default_policy_excludes_ordinary_claude_subagents(monkeypatch, tmp_path): monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.delenv(BRAINLAYER_INGEST_DENYLIST_ENV, raising=False) projects = tmp_path / ".claude" / "projects" / "-Users-test-Gits-brainlayer" / "session-uuid" @@ -47,8 +47,8 @@ def test_default_policy_allows_ordinary_claude_subagents(monkeypatch, tmp_path): explore = _write_subagent(projects / "subagents" / "agent-explore.jsonl", "Explore") general = _write_subagent(projects / "subagents" / "agent-general.jsonl", "general-purpose") - assert not is_denylisted(explore) - assert not is_denylisted(general) + assert is_denylisted(explore) + assert is_denylisted(general) def test_default_policy_excludes_exact_brain_worker_but_keeps_raw_jsonl(monkeypatch, tmp_path): @@ -89,7 +89,7 @@ def test_default_policy_excludes_workflow_workers_by_path_and_keeps_raw_jsonl(mo assert workflow.exists() -def test_unattributed_subagent_is_deferred_until_identity_is_known(monkeypatch, tmp_path): +def test_subagent_attribution_becomes_known_after_append(monkeypatch, tmp_path): monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.delenv(BRAINLAYER_INGEST_DENYLIST_ENV, raising=False) worker = _write_subagent( @@ -97,7 +97,8 @@ def test_unattributed_subagent_is_deferred_until_identity_is_known(monkeypatch, None, ) - assert is_denylisted(worker) + denylist._SUBAGENT_ATTRIBUTION_CACHE.clear() + assert denylist._claude_subagent_attribution(worker) is None with worker.open("a", encoding="utf-8") as handle: handle.write( @@ -111,13 +112,15 @@ def test_unattributed_subagent_is_deferred_until_identity_is_known(monkeypatch, + "\n" ) - assert not is_denylisted(worker) + assert denylist._claude_subagent_attribution(worker) == "general-purpose" def test_historical_policy_preserves_unverifiable_subagents(monkeypatch, tmp_path): monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.delenv(BRAINLAYER_INGEST_DENYLIST_ENV, raising=False) - missing_worker = tmp_path / ".claude" / "projects" / "proj" / "session" / "subagents" / "agent-missing.jsonl" + missing_worker = ( + tmp_path / ".claude" / "historical" / "projects" / "proj" / "session" / "subagents" / "agent-missing.jsonl" + ) assert is_denylisted(missing_worker) assert not is_denylisted(missing_worker, unknown_subagent_is_denylisted=False) @@ -133,7 +136,7 @@ def test_subagent_attribution_skips_non_object_json_values(monkeypatch, tmp_path encoding="utf-8", ) - assert not is_denylisted(worker) + assert denylist._claude_subagent_attribution(worker) == "general-purpose" def test_subagent_attribution_is_found_after_legacy_scan_limit(monkeypatch, tmp_path): @@ -147,7 +150,7 @@ def test_subagent_attribution_is_found_after_legacy_scan_limit(monkeypatch, tmp_ encoding="utf-8", ) - assert not is_denylisted(worker) + assert denylist._claude_subagent_attribution(worker) == "general-purpose" def test_unattributed_appends_are_scanned_incrementally(monkeypatch, tmp_path): @@ -168,13 +171,13 @@ def counting_loads(value): monkeypatch.setattr(denylist.json, "loads", counting_loads) - assert is_denylisted(worker) + assert denylist._claude_subagent_attribution(worker) is None with worker.open("a", encoding="utf-8") as handle: handle.write(progress + "\n") - assert is_denylisted(worker) + assert denylist._claude_subagent_attribution(worker) is None with worker.open("a", encoding="utf-8") as handle: handle.write(json.dumps({"attributionAgent": "general-purpose"}) + "\n") - assert not is_denylisted(worker) + assert denylist._claude_subagent_attribution(worker) == "general-purpose" assert calls == 202 @@ -186,7 +189,7 @@ def test_cached_attribution_is_invalidated_when_same_inode_is_rewritten_larger(m tmp_path / ".claude" / "projects" / "proj" / "session" / "subagents" / "agent.jsonl", "general-purpose", ) - assert not is_denylisted(worker) + assert denylist._claude_subagent_attribution(worker) == "general-purpose" initial_stat = worker.stat() worker.write_text( @@ -204,7 +207,7 @@ def test_cached_attribution_is_invalidated_when_same_inode_is_rewritten_larger(m assert rewritten_stat.st_ino == initial_stat.st_ino assert rewritten_stat.st_size > initial_stat.st_size - assert is_denylisted(worker) + assert denylist._claude_subagent_attribution(worker) == "brain-worker" def test_explicit_empty_override_disables_default_subagent_policy(monkeypatch, tmp_path): diff --git a/tests/test_watcher_bridge.py b/tests/test_watcher_bridge.py index 43c9cee7..66921904 100644 --- a/tests/test_watcher_bridge.py +++ b/tests/test_watcher_bridge.py @@ -500,7 +500,7 @@ def test_auto_tags_detected_correction_user_message(self, tmp_path): class TestFullPipeline: - def test_watcher_denylist_blocks_only_brain_and_workflow_workers(self, tmp_path, monkeypatch): + def test_watcher_denylist_blocks_all_claude_subagents_and_allows_direct_sessions(self, tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.delenv("BRAINLAYER_INGEST_DENYLIST", raising=False) db_path = tmp_path / "test.db" @@ -523,8 +523,6 @@ def test_watcher_denylist_blocks_only_brain_and_workflow_workers(self, tmp_path, / "workflows" / "wf_123" / "agent-workflow.jsonl", - } - allowed = { "claude-subagent": tmp_path / ".claude" / "projects" @@ -532,6 +530,8 @@ def test_watcher_denylist_blocks_only_brain_and_workflow_workers(self, tmp_path, / "session-123" / "subagents" / "agent-general.jsonl", + } + allowed = { "codex": tmp_path / ".codex" / "sessions" / "2026" / "07" / "02" / "worker.jsonl", "cursor": tmp_path / ".cursor" @@ -545,7 +545,11 @@ def test_watcher_denylist_blocks_only_brain_and_workflow_workers(self, tmp_path, } for provider, path in denylisted.items(): path.parent.mkdir(parents=True, exist_ok=True) - attribution = "brain-worker" if provider == "brain-worker" else "workflow-subagent" + attribution = { + "brain-worker": "brain-worker", + "workflow-worker": "workflow-subagent", + "claude-subagent": "general-purpose", + }[provider] path.write_text( json.dumps( _make_jsonl_entry(