diff --git a/CHANGELOG.md b/CHANGELOG.md index 298322ae..a653dceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## [Unreleased] ### Security +- **Cross-repo symlink write guard** (Issue #1019, closes silent cross-repo mutation trap): Adds `_is_cross_repo_protected_write()` helper to `unified_pre_tool.py`. When a session in a consumer repo (e.g., `~/Dev/realign`) follows a `plugins -> ~/Dev/autonomous-dev/plugins` symlink and attempts to write a protected infrastructure file (`hooks/*.py`, `lib/*.py`, `agents/*.md`, etc.), the hook now compares the cwd's resolved repo root against the resolved file's repo root; a mismatch blocks the write regardless of pipeline state. Previously, `_is_pipeline_active()` returning `True` (for the foreign repo's pipeline) caused the edit to be silently allowed, mutating autonomous-dev's source tree. The guard is fail-open: a false negative preserves the existing same-repo pipeline-active check. Wired into: Write/Edit gate (`_check_write_edit_protected`, line ~4254) and Bash gate (`_check_bash_infra_writes`, line ~3518); the Bash gate's pipeline-active short-circuit was refactored from an early return to a per-path `continue` so cross-repo writes are caught even when a pipeline is active. Block message directs to `deploy-all.sh`. 10 new test functions in `tests/unit/hooks/test_cross_repo_symlink_guard.py`: helper unit tests, Write/Edit flow, Bash flow, and deny-cache integration. + - **Phase 3 prompt-injection defense — extend `_wrap_user_input` across GenAI callers** (Issue #1007, follow-up to #960): Adopts the Phase 2 wrapping helper across 8 additional `GenAIAnalyzer` callers (10 sites total), giving the codebase uniform prompt-injection defense at every LLM integration point. New helper `_safe_wrap(text)` in `plugins/autonomous-dev/hooks/genai_utils.py` simplifies adoption (single-line invocation, never raises). Modified callers — Pattern A (kwarg substitution): `complexity_assessor.py` (`feature_description`), `scope_detector.py` (`issue_text`), `issue_scope_detector.py` (`issue_text`), `alignment_assessor.py` (2 sites: `dependencies_sample`/`config_files`/`readme_content`; enum-constrained scalars like `primary_language` left unwrapped), `genai_refactor_analyzer.py` (5 sites covering `doc_content`/`source_content`/`test_source`/`source_under_test`/`function_source`/`references_summary`/`original_analysis`), `security_scan.py` (`line`+`variable_name`; `secret_type` regex-catalog constant unwrapped), `auto_fix_docs.py` (`item_name`; `item_type` constrained literal unwrapped). Pattern B (.format pre-substitution): `feature_completion_detector.py` wraps both `feature` and `evidence` at `.format()` time since `analyzer.analyze()` receives the already-formatted prompt. Audit verified 4 false positives (`error_analyzer.py`, `codebase_analyzer.py`, `enforce_prunable_threshold.py`, `align_project_retrofit.py`) do NOT call `GenAIAnalyzer` and are NOT modified — locked by a parametrized regression test. 26 new test functions: 4 in `tests/unit/hooks/test_genai_utils_safe_wrap.py` (helper guarantees: wraps, escapes, coerces non-string, never raises), 21 in `tests/unit/lib/test_phase3_wrap_adoption.py` (8 wrap-adoption tests parametrized over multi-site files, plus 4 false-positive lock tests), and 1 existing-test adjustment in `tests/unit/lib/test_scope_detector_genai.py` (assertion updated from raw to wrapped form). `docs/INTENT-CLASSIFICATION.md` and `CHANGELOG.md` updated. ### Added diff --git a/CLAUDE.md b/CLAUDE.md index cf544efb..6f9cf585 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,9 @@ Development harness for Claude Code. Deterministic enforcement, specialist agent - **Run `/improve` after `/implement` sessions.** Use `--auto-file` to create GitHub issues. - **Use `/clear` after each feature.** Prevents context bloat. - **Deploy with `bash scripts/deploy-all.sh`** — never manual `cp -rf`. Script handles local, remote (Mac Studio), validation, and integrity checks. -- **Don't simplify, redesign, or consolidate agents.** The pipeline, hooks, and enforcement are validated over months of real use. The cost is tokens, not complexity. Complexity is the mechanism. +- **Process applies WHEN doing software development.** The pipeline, hooks, and enforcement exist to ensure thoroughness on real feature work for repos. They should NOT fire on testing, exploration, scratch edits, or non-repo work. The job is to detect *when* software development is happening and apply the right level of process — not to gate every edit. Context-blind enforcement is the bug; intent-aware enforcement is the goal. +- **Hook deadlock protocol**: If a hook blocks legitimate work, surface the block to the user with the proposed bypass and *wait for approval*. Do NOT autonomously create `.claude/.bypass` or set `AUTONOMOUS_DEV_BYPASS=1` — those signals exist for the user to authorize, not for the model to invoke. See [plugins/autonomous-dev/docs/TROUBLESHOOTING.md](plugins/autonomous-dev/docs/TROUBLESHOOTING.md) for bypass mechanics. +- **Plugin source of truth**: `~/Dev/autonomous-dev/plugins/autonomous-dev/` is canonical. Consumer repos (anyclaude, realign, spektiv, homeassistant) receive *copies* via `bash scripts/deploy-all.sh` — never symlinks. A symlink found by `find ~/Dev -maxdepth 4 -name plugins -type l` is a silent cross-repo write trap (Issue #1021); replace with a `deploy-all.sh` copy. See [docs/ARCHITECTURE-OVERVIEW.md](docs/ARCHITECTURE-OVERVIEW.md#distribution-model). ## Build & Test @@ -122,3 +124,50 @@ grep -l "search term" ~/.claude/archive/conversations/**/*.jsonl **Note on `project` values:** Derived from `cwd` basename — worktrees and subdirectories (e.g., `spektiv/frontend`) get their own project row rather than rolling into the parent repo. **Last Updated**: 2026-04-21 +## Documentation Alignment + +CLAUDE.md alignment system prevents documentation drift. Automatic validation that CLAUDE.md stays in sync with PROJECT.md and codebase. + +**How to check**: +```bash +# Automatic (every commit) +git commit # Pre-commit hook validates CLAUDE.md alignment + +# Manual check +python plugins/autonomous-dev/scripts/validate_claude_alignment.py +``` + +**What gets validated**: +- Version dates (CLAUDE.md shouldn't be older than PROJECT.md) +- Agent counts (documented count matches reality) +- Command availability (documented commands exist) +- Skills status (removed per v2.5.0 guidance) +- Hook documentation (matches implemented hooks) + +**If drift detected**: +1. Run validation script to see specific issues +2. Update CLAUDE.md to match actual state +3. Commit the fix +4. Pre-commit hook validates on next commit + +## Maintaining Core Philosophy + +**The Golden Rule**: UPDATE PROJECT.md FIRST, everything else follows +- PROJECT.md = source of truth for alignment +- orchestrator reads it before any feature work +- Hooks validate against it on every commit + +**Priority Updates**: +1. 🔴 ALWAYS: PROJECT.md, orchestrator.md, settings.local.json +2. 🟡 FREQUENTLY: Agent prompts, GenAI prompts, skills +3. 🟢 PERIODICALLY: Documentation, session logs, hooks + +**Philosophy Checklist** (before major changes): +- ✅ Trust the model? (GenAI reasoning > rigid Python) +- ✅ Enforce via hooks? (100% reliable blocking) +- ✅ Enhance via agents? (conditional intelligence) +- ✅ PROJECT.md controls alignment? (dynamic scope) +- ✅ Skills used progressively? (no context bloat) +- ✅ Documentation auto-syncs? (no drift) + +See `docs/MAINTAINING-PHILOSOPHY.md` for comprehensive guide. diff --git a/docs/ARCHITECTURE-OVERVIEW.md b/docs/ARCHITECTURE-OVERVIEW.md index 197deeed..02ea101b 100644 --- a/docs/ARCHITECTURE-OVERVIEW.md +++ b/docs/ARCHITECTURE-OVERVIEW.md @@ -280,6 +280,20 @@ autonomous-dev uses a **Diamond Model** — not the traditional TDD pyramid. Acc --- +## Distribution Model + +**Canonical source of truth**: `~/Dev/autonomous-dev/plugins/autonomous-dev/`. All other locations are *copies*, distributed via `bash scripts/deploy-all.sh`. Symlinks from consumer repos into this tree are unsupported because they cause silent cross-repo writes (see Issue #1021). + +**Per-repo install**: `deploy-all.sh` rsyncs `hooks/`, `commands/`, `agents/`, `lib/`, `templates/`, `config/`, `skills/`, `scripts/` into each consumer repo's `.claude/` directory. Each repo therefore owns its plugin copy; updates require explicit redeploy. + +**Global cache**: `deploy-all.sh` also syncs `hooks/`, `lib/`, `config/` to `~/.claude/` so plugin code is reachable from any cwd. Hook *registration* in `~/.claude/settings.json` is opt-in via `--global-settings` (Issue #995). + +**Repos in scope**: `LOCAL_REPOS` / `REMOTE_REPOS` env vars in `deploy-all.sh` (default: `autonomous-dev anyclaude realign spektiv homeassistant`). + +**Audit invariant**: `find ~/Dev -maxdepth 4 -name plugins -type l` should return zero results. Any symlink found is a cross-repo silent-write trap and must be replaced with a `deploy-all.sh` copy. The `_is_cross_repo_protected_write()` guard in `unified_pre_tool.py` (Issue #1019) now actively blocks Write/Edit/Bash writes that resolve across repo boundaries — so even if a symlink exists, the hook will deny the write before it silently mutates the foreign source tree. + +--- + ## Cross-References **Related Documentation**: diff --git a/docs/HOOKS.md b/docs/HOOKS.md index a8156235..4b06c083 100644 --- a/docs/HOOKS.md +++ b/docs/HOOKS.md @@ -127,6 +127,7 @@ This distinction is fundamental: nudges in `systemMessage` are user-readable but **Infrastructure Protection** (scoped to autonomous-dev repos): - Write/Edit to `agents/*.md`, `commands/*.md`, `hooks/*.py`, `lib/*.py`, `skills/*/SKILL.md` are blocked outside the `/implement` pipeline +- **Cross-repo symlink guard** (Issue #1019): `_is_cross_repo_protected_write()` additionally blocks any Write/Edit/Bash write that resolves via symlink into a protected path in a *different* repo root, regardless of pipeline state. This closes the silent cross-repo mutation trap where `~/Dev/realign/plugins -> ~/Dev/autonomous-dev/plugins` caused writes in the realign session to silently modify the autonomous-dev source tree. Detection: if `abs_path != abs_path.resolve()` (symlink in play) AND the resolved path is protected infrastructure AND the cwd's owning repo root differs from the resolved file's owning repo root — block. Fails open (false negative preserves existing pipeline-active check). Block message directs to `deploy-all.sh`. - Scoped to autonomous-dev repos (detected via `_is_autonomous_dev_repo()`) — does not affect user projects - User-facing docs (`README.md`, `CHANGELOG.md`, `docs/*.md`), config files (`.json`/`.yaml`), and all non-infrastructure paths are unaffected diff --git a/plugins/autonomous-dev/commands/implement.md b/plugins/autonomous-dev/commands/implement.md index b4f6f255..4f5de166 100644 --- a/plugins/autonomous-dev/commands/implement.md +++ b/plugins/autonomous-dev/commands/implement.md @@ -119,39 +119,22 @@ Before EVERY Agent tool dispatch, you MUST run this inline verification. This ca ```bash python3 -c " -import sys, os, json, time -for _p in ('.claude/lib', 'plugins/autonomous-dev/lib', os.path.expanduser('~/.claude/lib')): - if os.path.isdir(_p): - sys.path.insert(0, _p) - break - # Session-ID fallback chain (Issue #904): # 1. CLAUDE_SESSION_ID env var (primary — set in-process by Claude Code) # 2. /tmp/implement_pipeline_state.json['session_id'] (sentinel written at STEP 0) # — only honored when mtime is within 3600s (avoids cross-pipeline bleed) # 3. 'unknown' (preserved legacy sentinel — first-boot/pre-STEP-0 case) -def _resolve_session_id(): - sid = os.environ.get('CLAUDE_SESSION_ID', '').strip() - if sid and sid != 'unknown': - return sid - sentinel = '/tmp/implement_pipeline_state.json' - try: - if os.path.exists(sentinel): - mtime = os.path.getmtime(sentinel) - if time.time() - mtime < 3600: - with open(sentinel) as _f: - _state = json.load(_f) - _recovered = str(_state.get('session_id', '')).strip() - if _recovered and _recovered != 'unknown': - return _recovered - except (OSError, ValueError, json.JSONDecodeError): - pass - return 'unknown' +import sys, os +for _p in ('.claude/lib', 'plugins/autonomous-dev/lib', os.path.expanduser('~/.claude/lib')): + if os.path.isdir(_p): + sys.path.insert(0, _p) + break +from pipeline_completion_state import resolve_session_id from agent_ordering_gate import check_ordering_with_session_fallback result = check_ordering_with_session_fallback( 'TARGET_AGENT', - _resolve_session_id(), + resolve_session_id(), issue_number=ISSUE_NUMBER_OR_0, pipeline_mode='MODE' ) @@ -237,35 +220,19 @@ save_pipeline(state) print(f'Pipeline {state.run_id} initialized') " python3 -c " -import sys, os, json, time +import json +import sys, os for _p in ('.claude/lib', 'plugins/autonomous-dev/lib', os.path.expanduser('~/.claude/lib')): if os.path.isdir(_p): sys.path.insert(0, _p) break from pipeline_state import sign_state +from pipeline_completion_state import resolve_session_id # Session-ID fallback chain (Issue #904): env → sentinel → 'unknown'. # Honor a prior-written sentinel when the env var was dropped by a # subshell, e.g. /implement --resume re-entering STEP 0. -def _resolve_session_id(): - sid = os.environ.get('CLAUDE_SESSION_ID', '').strip() - if sid and sid != 'unknown': - return sid - sentinel = '/tmp/implement_pipeline_state.json' - try: - if os.path.exists(sentinel): - mtime = os.path.getmtime(sentinel) - if time.time() - mtime < 3600: - with open(sentinel) as _f: - _state = json.load(_f) - _recovered = str(_state.get('session_id', '')).strip() - if _recovered and _recovered != 'unknown': - return _recovered - except (OSError, ValueError, json.JSONDecodeError): - pass - return 'unknown' - -sid = _resolve_session_id() +sid = resolve_session_id() state = { 'session_start': '$(date +%Y-%m-%dT%H:%M:%S)', 'mode': 'MODE', @@ -367,41 +334,25 @@ Read `.claude/PROJECT.md`. If missing: BLOCK ("Run `/setup` or `/align --retrofi ```bash python3 -c " -import sys, os, json, time +import json +import sys, os for _p in ('.claude/lib', 'plugins/autonomous-dev/lib', os.path.expanduser('~/.claude/lib')): if os.path.isdir(_p): sys.path.insert(0, _p) break from pipeline_state import sign_state +from pipeline_completion_state import resolve_session_id # Session-ID fallback chain (Issue #904): env → sentinel → 'unknown'. # In a subshell that lost CLAUDE_SESSION_ID (e.g., nested heredoc in a # pipe), recover the real session_id from the STEP-0 sentinel instead of # re-signing the state as 'unknown' (which would break HMAC verification). -def _resolve_session_id(): - sid = os.environ.get('CLAUDE_SESSION_ID', '').strip() - if sid and sid != 'unknown': - return sid - sentinel = '/tmp/implement_pipeline_state.json' - try: - if os.path.exists(sentinel): - mtime = os.path.getmtime(sentinel) - if time.time() - mtime < 3600: - with open(sentinel) as _f: - _state = json.load(_f) - _recovered = str(_state.get('session_id', '')).strip() - if _recovered and _recovered != 'unknown': - return _recovered - except (OSError, ValueError, json.JSONDecodeError): - pass - return 'unknown' - state_path = '/tmp/implement_pipeline_state.json' if os.path.exists(state_path): with open(state_path) as f: state = json.load(f) state['alignment_passed'] = True - sid = _resolve_session_id() + sid = resolve_session_id() state = sign_state(state, sid) with open(state_path, 'w') as f: json.dump(state, f) diff --git a/plugins/autonomous-dev/hooks/stop_quality_gate.py b/plugins/autonomous-dev/hooks/stop_quality_gate.py index e802dfa3..b991ce95 100755 --- a/plugins/autonomous-dev/hooks/stop_quality_gate.py +++ b/plugins/autonomous-dev/hooks/stop_quality_gate.py @@ -532,6 +532,21 @@ def main() -> int: # Format and output results output = format_results(results) + + # Auto-record pytest-gate completion for active /implement pipelines (Issue #802 wiring). + # 'unknown' guard: only record when a real session_id is resolved, so casual + # end-of-turn pytest passes outside a pipeline don't pollute state. + try: + if results["pytest"].get("ran") and results["pytest"].get("passed") is True: + from hook_stdin import read_stdin_once, extract_session_id + from pipeline_completion_state import resolve_session_id, record_pytest_gate_passed + stdin_data = read_stdin_once() + sid = extract_session_id(stdin_data) or resolve_session_id() + if sid and sid != "unknown": + record_pytest_gate_passed(sid, issue_number=0) + except Exception: + pass # Non-blocking: recording failure must never block the Stop hook + sys.stderr.write(output) except Exception as e: diff --git a/plugins/autonomous-dev/hooks/unified_pre_tool.py b/plugins/autonomous-dev/hooks/unified_pre_tool.py index b0ad0660..5b5097ff 100755 --- a/plugins/autonomous-dev/hooks/unified_pre_tool.py +++ b/plugins/autonomous-dev/hooks/unified_pre_tool.py @@ -1150,6 +1150,75 @@ def _is_protected_infrastructure(file_path: str) -> bool: return False +def _is_cross_repo_protected_write(file_path: str) -> tuple: + """Detect cross-repo writes via symlinks into a foreign autonomous-dev repo. + + Returns (True, reason) when ALL hold: + 1. file_path's absolute (un-resolved) form differs from its resolved form + (i.e., a symlink is in play) + 2. The resolved file lives in a protected autonomous-dev location + (delegates to _is_protected_infrastructure) + 3. The cwd's owning autonomous-dev repo root differs from the resolved + file's owning autonomous-dev repo root + + Returns (False, None) when same-repo, no symlink in play, or path + resolution fails. This is a *fail-open* helper by design — its caller + (the Write/Edit gate) still applies the existing pipeline_active check + afterward, so a false negative here just preserves current behavior. + + Args: + file_path: Absolute or relative path to the file being written/edited. + + Returns: + Tuple of (is_cross_repo: bool, reason: str | None). + """ + if not file_path: + return (False, None) + try: + abs_path = Path(file_path).expanduser().absolute() + resolved_path = abs_path.resolve() + # No symlink in play if absolute and resolved forms are identical + if abs_path == resolved_path: + return (False, None) + # The resolved file must be in a protected location + if not _is_protected_infrastructure(str(resolved_path)): + return (False, None) + + # Walk up from resolved file to find its owning repo root (10-level cap) + def _find_repo_root(start: Path) -> Path | None: + current = start if start.is_dir() else start.parent + for _ in range(10): + marker = current / ".claude" / "commands" / "implement.md" + if marker.exists(): + return current + parent = current.parent + if parent == current: + break + current = parent + return None + + resolved_root = _find_repo_root(resolved_path) + cwd_root = _find_repo_root(Path.cwd().resolve()) + + if resolved_root is None or cwd_root is None: + return (False, None) + + if resolved_root == cwd_root: + return (False, None) + + reason = ( + f"Cross-repo symlink write detected: '{resolved_path}' " + f"belongs to repo '{resolved_root}' but the session cwd belongs to " + f"repo '{cwd_root}'. Writing cross-repo via symlink is not allowed. " + f"Use 'bash scripts/deploy-all.sh' to propagate changes (Issue #1021). " + f"REQUIRED NEXT ACTION: Do not write directly across repos via symlinks. " + f"Deploy via deploy-all.sh instead." + ) + return (True, reason) + except (OSError, ValueError): + return (False, None) + + def _get_active_agent_name() -> str: """Get the active agent name from available sources (Issue #591). @@ -3467,13 +3536,6 @@ def _check_bash_infra_writes(command: str) -> "Optional[Tuple[str, str]]": """ import re - # If pipeline is active, allow everything (same as Write/Edit behavior) - try: - if _is_pipeline_active(): - return None - except Exception: - pass # If check fails, continue with inspection - # Collect candidate target file paths from various write patterns target_paths = [] # type: list @@ -3557,12 +3619,33 @@ def _check_bash_infra_writes(command: str) -> "Optional[Tuple[str, str]]": for path_rename_match in re.finditer(path_rename_pattern, snippet): target_paths.append(path_rename_match.group(1)) + # Capture pipeline state once — cross-repo writes are blocked regardless, + # but same-repo protected writes are allowed when pipeline is active. + pipeline_active = False + try: + pipeline_active = _is_pipeline_active() + except Exception: + pass # If check fails, treat as not active (conservative) + # Check each target path against protected infrastructure for fp in target_paths: fp = fp.strip().strip("'\"") if not fp: continue try: + # Issue #1019: Cross-repo symlink write guard — check BEFORE pipeline-active + # short-circuit so foreign-repo writes are always blocked, even during /implement. + try: + is_cross_repo, cross_repo_reason = _is_cross_repo_protected_write(fp) + if is_cross_repo: + file_name = Path(fp).name + _update_deny_cache(fp) + return (file_name, cross_repo_reason) + except Exception: + pass # Fall through to existing _is_protected_infrastructure check + # Same-repo protected paths: allow when pipeline is active + if pipeline_active: + continue if _is_protected_infrastructure(fp): file_name = Path(fp).name # Check deny cache for escalation (Issue #558) @@ -4176,6 +4259,27 @@ def main(): file_path = tool_input.get("file_path", "") try: is_protected = _is_protected_infrastructure(file_path) + # Issue #1019: Cross-repo symlink write guard — block even when + # a pipeline is active if the resolved file's repo root differs + # from the cwd's repo root (symlink into foreign repo). + if is_protected: + is_cross_repo, cross_repo_reason = _is_cross_repo_protected_write(file_path) + if is_cross_repo: + file_name = Path(file_path).name if file_path else "unknown" + _log_deviation(file_name, tool_name, "cross_repo_symlink_write_block") + _log_pretool_activity(tool_name, tool_input, "deny", cross_repo_reason) + output_decision( + "deny", cross_repo_reason, + system_message=( + f"BLOCKED: Cross-repo write to '{file_name}' via symlink. " + f"See Issue #1021." + ), + ) + try: + _update_deny_cache(file_path) + except Exception: + pass + sys.exit(0) pipeline_active = _is_pipeline_active() if is_protected else False except Exception: # Fail closed — if protection check errors, treat as protected diff --git a/plugins/autonomous-dev/lib/pipeline_completion_state.py b/plugins/autonomous-dev/lib/pipeline_completion_state.py index c2594a46..fde4e3a0 100644 --- a/plugins/autonomous-dev/lib/pipeline_completion_state.py +++ b/plugins/autonomous-dev/lib/pipeline_completion_state.py @@ -26,6 +26,52 @@ # Issue #802 SKIP_GATE_FILE = Path("/tmp/skip_agent_completeness_gate") + +# Pipeline state sentinel written at /implement STEP 0. Used by +# resolve_session_id() to recover the real session_id when +# CLAUDE_SESSION_ID is not propagated to a subshell. Issue #904. +_IMPLEMENT_STATE_SENTINEL = Path("/tmp/implement_pipeline_state.json") +_SESSION_SENTINEL_TTL_SECONDS = 3600 + + +def resolve_session_id() -> str: + """Resolve the active pipeline session_id with a durable fallback chain. + + Single source of truth for subshell session_id resolution. Subshells that + inline this chain inconsistently produce divergent state files (Issue #904 + repro: coordinator wrote with 'unknown', hook read with real session_id — + research_skipped flag never visible to commit-time gate). + + Resolution order: + 1. ``CLAUDE_SESSION_ID`` env var (primary — set in-process by Claude Code). + 2. ``/tmp/implement_pipeline_state.json`` sentinel — only honored when + the file's mtime is within ``_SESSION_SENTINEL_TTL_SECONDS`` to avoid + cross-pipeline bleed. + 3. ``'unknown'`` sentinel string. Callers that record state under + 'unknown' should expect it to be invisible to the commit-time gate + (which uses the real session_id from the harness). + + Returns: + Non-empty string. Either a real UUID, recovered sentinel value, or + the literal ``'unknown'``. + """ + sid = os.environ.get("CLAUDE_SESSION_ID", "").strip() + if sid and sid != "unknown": + return sid + try: + if _IMPLEMENT_STATE_SENTINEL.exists(): + mtime = _IMPLEMENT_STATE_SENTINEL.stat().st_mtime + if time.time() - mtime < _SESSION_SENTINEL_TTL_SECONDS: + with open(_IMPLEMENT_STATE_SENTINEL) as f: + state = json.load(f) + recovered = str(state.get("session_id", "")).strip() + if recovered and recovered != "unknown": + return recovered + except (OSError, ValueError, json.JSONDecodeError): + pass + return "unknown" + + # Staleness TTL for the 'unknown' session-id fallback merge. # When the primary-session lookup in get_completed_agents() falls back to # reading the 'unknown' state file (for the Issue #738/#777 in-flight boot diff --git a/plugins/autonomous-dev/tests/test_pipeline_completion_state.py b/plugins/autonomous-dev/tests/test_pipeline_completion_state.py new file mode 100644 index 00000000..894b79d1 --- /dev/null +++ b/plugins/autonomous-dev/tests/test_pipeline_completion_state.py @@ -0,0 +1,52 @@ +"""Tests for pipeline_completion_state.resolve_session_id() — Issue #904.""" +import json +import os +import sys +import time +from pathlib import Path + +import pytest + +_HERE = Path(__file__).resolve() +_LIB = _HERE.parents[1] / "lib" +if str(_LIB) not in sys.path: + sys.path.insert(0, str(_LIB)) + +import pipeline_completion_state as pcs + + +class TestResolveSessionId: + def test_env_var_takes_precedence(self, monkeypatch, tmp_path): + monkeypatch.setenv("CLAUDE_SESSION_ID", "abc-123") + sentinel = tmp_path / "sentinel.json" + sentinel.write_text(json.dumps({"session_id": "from-sentinel"})) + monkeypatch.setattr(pcs, "_IMPLEMENT_STATE_SENTINEL", sentinel) + assert pcs.resolve_session_id() == "abc-123" + + def test_falls_back_to_sentinel_when_env_unset(self, monkeypatch, tmp_path): + monkeypatch.delenv("CLAUDE_SESSION_ID", raising=False) + sentinel = tmp_path / "sentinel.json" + sentinel.write_text(json.dumps({"session_id": "from-sentinel"})) + monkeypatch.setattr(pcs, "_IMPLEMENT_STATE_SENTINEL", sentinel) + assert pcs.resolve_session_id() == "from-sentinel" + + def test_ignores_stale_sentinel(self, monkeypatch, tmp_path): + monkeypatch.delenv("CLAUDE_SESSION_ID", raising=False) + sentinel = tmp_path / "sentinel.json" + sentinel.write_text(json.dumps({"session_id": "stale-id"})) + old = time.time() - (pcs._SESSION_SENTINEL_TTL_SECONDS + 60) + os.utime(sentinel, (old, old)) + monkeypatch.setattr(pcs, "_IMPLEMENT_STATE_SENTINEL", sentinel) + assert pcs.resolve_session_id() == "unknown" + + def test_returns_unknown_with_no_signal(self, monkeypatch, tmp_path): + monkeypatch.delenv("CLAUDE_SESSION_ID", raising=False) + nonexistent = tmp_path / "no-sentinel.json" + monkeypatch.setattr(pcs, "_IMPLEMENT_STATE_SENTINEL", nonexistent) + assert pcs.resolve_session_id() == "unknown" + + def test_env_unknown_treated_as_no_signal(self, monkeypatch, tmp_path): + monkeypatch.setenv("CLAUDE_SESSION_ID", "unknown") + nonexistent = tmp_path / "no-sentinel.json" + monkeypatch.setattr(pcs, "_IMPLEMENT_STATE_SENTINEL", nonexistent) + assert pcs.resolve_session_id() == "unknown" diff --git a/plugins/autonomous-dev/tests/test_stop_quality_gate.py b/plugins/autonomous-dev/tests/test_stop_quality_gate.py new file mode 100644 index 00000000..9fd27da7 --- /dev/null +++ b/plugins/autonomous-dev/tests/test_stop_quality_gate.py @@ -0,0 +1,68 @@ +"""Tests for stop_quality_gate.py auto pytest-gate recording — Issue #802 wiring.""" +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +_HERE = Path(__file__).resolve() +_PLUGIN_ROOT = _HERE.parents[1] +_LIB = _PLUGIN_ROOT / "lib" +_HOOKS = _PLUGIN_ROOT / "hooks" +for p in (str(_LIB), str(_HOOKS)): + if p not in sys.path: + sys.path.insert(0, p) + +import stop_quality_gate + + +class TestPytestGateAutoRecord: + def _passing_results(self): + return { + "pytest": {"ran": True, "passed": True, "stdout": "", "stderr": "", "error": None}, + "ruff": {"ran": False, "passed": None, "stdout": "", "stderr": "", "error": None}, + "mypy": {"ran": False, "passed": None, "stdout": "", "stderr": "", "error": None}, + } + + def _tools_pytest_only(self): + return { + "pytest": {"available": True, "config_file": "pytest.ini"}, + "ruff": {"available": False, "config_file": None}, + "mypy": {"available": False, "config_file": None}, + } + + def test_records_pytest_gate_when_session_real(self): + with patch.object(stop_quality_gate, "should_enforce_quality_gate", return_value=True), \ + patch.object(stop_quality_gate, "detect_project_tools", return_value=self._tools_pytest_only()), \ + patch.object(stop_quality_gate, "run_quality_checks", return_value=self._passing_results()), \ + patch("hook_stdin.read_stdin_once", return_value={"session_id": "real-uuid"}), \ + patch("pipeline_completion_state.record_pytest_gate_passed") as record_mock: + rc = stop_quality_gate.main() + assert rc == 0 + record_mock.assert_called_once() + args, kwargs = record_mock.call_args + sid_arg = args[0] if args else kwargs.get("session_id") + assert sid_arg == "real-uuid" + + def test_skips_record_when_session_unknown(self): + with patch.object(stop_quality_gate, "should_enforce_quality_gate", return_value=True), \ + patch.object(stop_quality_gate, "detect_project_tools", return_value=self._tools_pytest_only()), \ + patch.object(stop_quality_gate, "run_quality_checks", return_value=self._passing_results()), \ + patch("hook_stdin.read_stdin_once", return_value=None), \ + patch("pipeline_completion_state.resolve_session_id", return_value="unknown"), \ + patch("pipeline_completion_state.record_pytest_gate_passed") as record_mock: + rc = stop_quality_gate.main() + assert rc == 0 + record_mock.assert_not_called() + + def test_skips_record_when_pytest_failed(self): + results = self._passing_results() + results["pytest"]["passed"] = False + with patch.object(stop_quality_gate, "should_enforce_quality_gate", return_value=True), \ + patch.object(stop_quality_gate, "detect_project_tools", return_value=self._tools_pytest_only()), \ + patch.object(stop_quality_gate, "run_quality_checks", return_value=results), \ + patch("hook_stdin.read_stdin_once", return_value={"session_id": "real-uuid"}), \ + patch("pipeline_completion_state.record_pytest_gate_passed") as record_mock: + rc = stop_quality_gate.main() + assert rc == 0 + record_mock.assert_not_called() diff --git a/tests/unit/hooks/test_cross_repo_symlink_guard.py b/tests/unit/hooks/test_cross_repo_symlink_guard.py new file mode 100644 index 00000000..40e12181 --- /dev/null +++ b/tests/unit/hooks/test_cross_repo_symlink_guard.py @@ -0,0 +1,285 @@ +""" +Tests for cross-repo symlink write guard in unified_pre_tool.py (Issue #1019). + +Validates that: +1. _is_cross_repo_protected_write correctly detects cross-repo symlink writes +2. Same-repo writes are allowed (no regression) +3. Write/Edit gate blocks cross-repo writes even when pipeline is active +4. Bash heredoc/redirect gate blocks cross-repo writes +5. Deny cache is populated on cross-repo block + +Date: 2026-05-03 +""" + +import json +import os +import sys +from io import StringIO +from pathlib import Path +from unittest.mock import patch + +import pytest + +# Add hook's parent to path so we can import the module +HOOK_DIR = Path(__file__).resolve().parents[3] / "plugins" / "autonomous-dev" / "hooks" +sys.path.insert(0, str(HOOK_DIR)) + +# Also add lib dir for any transitive imports +LIB_DIR = Path(__file__).resolve().parents[3] / "plugins" / "autonomous-dev" / "lib" +sys.path.insert(0, str(LIB_DIR)) + +import unified_pre_tool as hook + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def clean_env(monkeypatch): + """Reset relevant env vars for each test.""" + env_keys = [ + "SANDBOX_ENABLED", "PRE_TOOL_MCP_SECURITY", "PRE_TOOL_AGENT_AUTH", + "PRE_TOOL_BATCH_PERMISSION", "MCP_AUTO_APPROVE", "ENFORCEMENT_LEVEL", + "CLAUDE_AGENT_NAME", "PIPELINE_STATE_FILE", + ] + for key in env_keys: + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("PRE_TOOL_MCP_SECURITY", "true") + monkeypatch.setenv("PRE_TOOL_AGENT_AUTH", "true") + + +@pytest.fixture() +def synthetic_repo_layout(tmp_path): + """Build a synthetic two-repo layout with a symlink from consumer into canonical. + + Layout: + tmp_path/ + ├── canonical/ + │ ├── .claude/commands/implement.md (marker) + │ └── plugins/autonomous-dev/lib/ + │ └── foo.py (protected file) + └── consumer/ + ├── .claude/commands/implement.md (marker) + └── plugins -> ../canonical/plugins (symlink) + + Yields: + (canonical_root, consumer_root, real_foo, symlinked_foo) + where real_foo is the canonical path and symlinked_foo is the path + via consumer's symlinked plugins directory. + """ + canonical = tmp_path / "canonical" + consumer = tmp_path / "consumer" + + # Create canonical repo structure + (canonical / ".claude" / "commands").mkdir(parents=True) + (canonical / ".claude" / "commands" / "implement.md").touch() + (canonical / "plugins" / "autonomous-dev" / "lib").mkdir(parents=True) + real_foo = canonical / "plugins" / "autonomous-dev" / "lib" / "foo.py" + real_foo.write_text("# real\n") + + # Create consumer repo structure with symlink into canonical plugins + (consumer / ".claude" / "commands").mkdir(parents=True) + (consumer / ".claude" / "commands" / "implement.md").touch() + # symlink: consumer/plugins -> ../canonical/plugins + (consumer / "plugins").symlink_to(canonical / "plugins") + + symlinked_foo = consumer / "plugins" / "autonomous-dev" / "lib" / "foo.py" + + yield (canonical, consumer, real_foo, symlinked_foo) + + +# --------------------------------------------------------------------------- +# TestCrossRepoHelperDirect +# --------------------------------------------------------------------------- + +class TestCrossRepoHelperDirect: + """Direct tests for _is_cross_repo_protected_write helper.""" + + def test_same_repo_edit_returns_false(self, synthetic_repo_layout, monkeypatch): + """Writing to a canonical path from within the canonical repo should return (False, None).""" + canonical_root, _, real_foo, _ = synthetic_repo_layout + monkeypatch.chdir(canonical_root) + result = hook._is_cross_repo_protected_write(str(real_foo)) + assert result == (False, None), f"Expected (False, None) but got {result}" + + def test_cross_repo_edit_via_symlink_returns_true(self, synthetic_repo_layout, monkeypatch): + """Writing to a protected file via a consumer-side symlink should return (True, reason).""" + canonical_root, consumer_root, _, symlinked_foo = synthetic_repo_layout + monkeypatch.chdir(consumer_root) + is_cross, reason = hook._is_cross_repo_protected_write(str(symlinked_foo)) + assert is_cross is True, f"Expected cross-repo=True but got {is_cross}" + assert reason is not None + assert str(canonical_root) in reason, f"Canonical root not in reason: {reason}" + assert str(consumer_root) in reason, f"Consumer root not in reason: {reason}" + + def test_non_symlinked_path_returns_false_same_cwd(self, synthetic_repo_layout, monkeypatch): + """Regular file path (no symlink) from canonical cwd returns (False, None).""" + canonical_root, _, real_foo, _ = synthetic_repo_layout + monkeypatch.chdir(canonical_root) + result = hook._is_cross_repo_protected_write(str(real_foo)) + assert result == (False, None) + + def test_non_symlinked_path_from_unrelated_cwd_returns_false( + self, synthetic_repo_layout, tmp_path, monkeypatch + ): + """From an unrelated cwd (no marker), fail-open: returns (False, None).""" + _, _, real_foo, _ = synthetic_repo_layout + unrelated_dir = tmp_path / "unrelated" + unrelated_dir.mkdir() + monkeypatch.chdir(unrelated_dir) + # real_foo has no symlink, and unrelated_dir has no autonomous-dev marker + result = hook._is_cross_repo_protected_write(str(real_foo)) + assert result == (False, None) + + def test_empty_path_returns_false(self, monkeypatch, tmp_path): + """Empty file path returns (False, None) without raising.""" + monkeypatch.chdir(tmp_path) + result = hook._is_cross_repo_protected_write("") + assert result == (False, None) + + def test_reason_contains_cross_repo_message(self, synthetic_repo_layout, monkeypatch): + """Reason string must mention 'Cross-repo symlink write detected'.""" + _, consumer_root, _, symlinked_foo = synthetic_repo_layout + monkeypatch.chdir(consumer_root) + is_cross, reason = hook._is_cross_repo_protected_write(str(symlinked_foo)) + assert is_cross is True + assert "Cross-repo symlink write detected" in reason + + +# --------------------------------------------------------------------------- +# TestCrossRepoMainFlow +# --------------------------------------------------------------------------- + +class TestCrossRepoMainFlow: + """Integration tests: cross-repo writes blocked via main hook flow.""" + + def _make_write_stdin(self, file_path: str) -> str: + """Build a fake hook stdin JSON for a Write tool call.""" + return json.dumps({ + "tool_name": "Write", + "tool_input": { + "file_path": file_path, + "content": "# injected\n", + }, + }) + + def test_cross_repo_write_blocked_via_main_flow( + self, synthetic_repo_layout, monkeypatch, capsys + ): + """Write via symlink from consumer cwd should produce deny decision.""" + _, consumer_root, _, symlinked_foo = synthetic_repo_layout + monkeypatch.chdir(consumer_root) + + stdin_data = self._make_write_stdin(str(symlinked_foo)) + monkeypatch.setattr("sys.stdin", StringIO(stdin_data)) + + # Pipeline active so the normal infra gate would allow — but cross-repo gate + # must fire first regardless of pipeline state. + monkeypatch.setattr(hook, "_is_pipeline_active", lambda: True) + + with pytest.raises(SystemExit): + hook.main() + + captured = capsys.readouterr() + output = json.loads(captured.out) + # output_decision produces hookSpecificOutput format + hook_output = output.get("hookSpecificOutput", {}) + perm_decision = hook_output.get("permissionDecision", "") + reason = hook_output.get("permissionDecisionReason", "") + assert perm_decision == "deny", f"Expected deny, got: {output}" + assert "Cross-repo symlink write detected" in reason, f"Unexpected reason: {reason}" + + def test_same_repo_write_not_blocked_by_cross_repo_gate( + self, synthetic_repo_layout, monkeypatch, capsys + ): + """Write to canonical path from canonical cwd must not be blocked by cross-repo gate.""" + canonical_root, _, real_foo, _ = synthetic_repo_layout + monkeypatch.chdir(canonical_root) + + stdin_data = self._make_write_stdin(str(real_foo)) + monkeypatch.setattr("sys.stdin", StringIO(stdin_data)) + + # Pipeline active — normal infra gate should allow; cross-repo must not fire + monkeypatch.setattr(hook, "_is_pipeline_active", lambda: True) + + # Should NOT raise SystemExit from the cross-repo gate (may exit via other paths) + # We only care that if it exits, it is NOT due to cross-repo deny + try: + hook.main() + except SystemExit: + pass + + captured = capsys.readouterr() + if captured.out.strip(): + try: + decision = json.loads(captured.out) + reason = decision.get("reason", "") + assert "Cross-repo symlink write detected" not in reason, ( + f"Same-repo write incorrectly blocked by cross-repo gate: {reason}" + ) + except json.JSONDecodeError: + pass # Non-JSON output is fine (e.g., allow with no output) + + +# --------------------------------------------------------------------------- +# TestCrossRepoBashGate +# --------------------------------------------------------------------------- + +class TestCrossRepoBashGate: + """Tests for cross-repo block in the Bash heredoc/redirect gate.""" + + def test_bash_heredoc_cross_repo_blocked(self, synthetic_repo_layout, monkeypatch): + """Bash command with heredoc to symlinked protected path should return cross-repo reason.""" + _, consumer_root, _, symlinked_foo = synthetic_repo_layout + monkeypatch.chdir(consumer_root) + + # Use the internal _check_bash_infra_writes helper directly + command = f"cat > {symlinked_foo!s} <