Skip to content
Open
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
22 changes: 22 additions & 0 deletions docs/plans/2026-07-18-default-deny-claude-subagents-design.md
Original file line number Diff line number Diff line change
@@ -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.
56 changes: 56 additions & 0 deletions docs/plans/2026-07-18-default-deny-claude-subagents.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion src/brainlayer/ingest_denylist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
31 changes: 17 additions & 14 deletions tests/test_ingest_denylist.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,16 @@ 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"

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):
Expand Down Expand Up @@ -89,15 +89,16 @@ 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(
tmp_path / ".claude" / "projects" / "proj" / "session-uuid" / "subagents" / "agent-new.jsonl",
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(
Expand All @@ -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)
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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


Expand All @@ -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(
Expand All @@ -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):
Expand Down
12 changes: 8 additions & 4 deletions tests/test_watcher_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -523,15 +523,15 @@ 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"
/ "proj"
/ "session-123"
/ "subagents"
/ "agent-general.jsonl",
}
allowed = {
"codex": tmp_path / ".codex" / "sessions" / "2026" / "07" / "02" / "worker.jsonl",
"cursor": tmp_path
/ ".cursor"
Expand All @@ -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(
Expand Down
Loading