diff --git a/CHANGELOG.md b/CHANGELOG.md index 803ad673..5fa365a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format ### Added +- **`git_gate` read-verb allowlist — raw read-only git for every branch.** The + PreToolUse `git_gate` previously blocked *all* raw git (forcing `drone @git` + even for harmless reads), which left agents unable to inspect what git ships — + the exact forensics needed to diagnose the audit gap above. It now allows 22 + read-only verbs raw (`ls-files`, `ls-tree`, `show`, `cat-file`, `rev-parse`, + `rev-list`, `log`, `status`, `diff`, `blame`, `archive`, `grep`, …) while + write operations stay `drone`-gated. Global options (`-C`, `-c`, `--git-dir`, + …) are skipped when extracting the verb, and chained commands are split on + `&&`/`||`/`;`/`|` so a read piped into a write still blocks the whole line. + (81 tests) - **Cross-OS end-to-end WIRING test (`tests/e2e/`, `e2e-wheel.yml`)** — the first CI gate that proves real AIPass *wiring* (not units-with-mocks) by building the wheel, installing it into a clean venv, and asserting a 4-tier ladder: package @@ -80,6 +90,21 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format ### Fixed +- **`seedgo-audit` CI gate was red despite 100% local audits — four checkers + validated the working tree instead of committed source.** CI audits a clean + `git checkout` (tracked files only — git ships no empty or gitignored dirs), + but the working tree carries runtime dirs (`logs/`, `*_json/`, `artifacts/`, + `.trinity/`, `passport.json`), so every branch scored ~97% in CI while passing + at 100% locally. Reproduced exactly with a tracked-only tree (`git archive HEAD` + audits to CI's 97%). Four checkers now measure what git actually ships: + `log_structure` no longer fails when the gitignored `logs/` dir is absent (it + still enforces no-hardcoded-paths); `readme` cross-references `.gitignore` + (via `git check-ignore` with a fallback list) and skips gitignored dirs/links + in the directory-tree and dead-link checks; `encapsulation` infers the branch + from the path when the gitignored `AIPASS_REGISTRY.json` is unavailable (and no + longer collides on the `aipass` branch); `architecture` skips cleanly when the + gitignored `passport.json` is absent. Clean-tree and working-tree audits now + both report 13/13 = 100%. (DPLAN-0195) - **Two latent Windows portability bugs caught by the new e2e harness** — both were always present in the code; they only surfaced now because this is the first CI to run `aipass init` scaffolding and real-branch `drone` routing on diff --git a/src/aipass/devpulse/.aipass/aipass_local_prompt.md b/src/aipass/devpulse/.aipass/aipass_local_prompt.md index f3e41f52..6fb0e16f 100644 --- a/src/aipass/devpulse/.aipass/aipass_local_prompt.md +++ b/src/aipass/devpulse/.aipass/aipass_local_prompt.md @@ -35,9 +35,15 @@ Task belongs to specialist domain → ask them. Investigate/fix small things you | User onboarding, init | @aipass | Concierge, aipass init, doctor, scanner | | Hooks, engine, gates | @hooks | Hook engine, bridges, per-project config, sound | -## Git — Dev Branch, Drone Only, You Are Gatekeeper +## Git — Dev Branch, You Are Gatekeeper -Only branch with git write access. All git/gh blocked at project level. Drone bypasses via subprocess — tier system grants write to devpulse only. +Only branch with git WRITE access. WRITE git (commit, push, checkout, merge, reset, rebase, clean, pull, fetch, tag, branch -D, clone, worktree…) is blocked raw → use `drone @git` (tier grants write to devpulse only). + +**READ git is allowed RAW** (S193, git_gate read allowlist) — just run it, no drone needed. Use this for investigation/forensics instead of reaching for drone or `find` fallbacks: +- Allowed verbs: `ls-files, ls-tree, show, cat-file, rev-parse, rev-list, log, status, diff, blame, describe, for-each-ref, show-ref, symbolic-ref, shortlog, grep, archive, count-objects, var, help, version`. +- NOT yet allowed (gap, S193): `check-ignore` → use `git ls-files ` (empty = ignored/untracked) or read `.gitignore` directly. +- Reproduce a clean checkout (tracked-only, like CI): `git archive HEAD | tar -x -C /tmp/` (`drone rm` the dir first; `rm -rf` is gated). +- Chained read+write blocks the whole command (e.g. `git log && git push` → blocked). Keep read and write in separate invocations. Three rules: 1. Work on dev, merge to main when satisfied. `drone @git merge dev` squash-merges. diff --git a/src/aipass/devpulse/tests/test_git_gate.py b/src/aipass/devpulse/tests/test_git_gate.py index 776356c4..25e6b6ed 100644 --- a/src/aipass/devpulse/tests/test_git_gate.py +++ b/src/aipass/devpulse/tests/test_git_gate.py @@ -140,8 +140,8 @@ class TestEscapedQuoteBypass: assert _is_blocked(_bash(cmd)) == should_block, f"{'Should block' if should_block else 'Should allow'}: {cmd}" -class TestReadOnlyBlocked: - """New handler blocks ALL raw git — read-only included. Use drone.""" +class TestReadVerbsAllowed: + """Read-only git verbs in the allowlist run raw (S193, DPLAN-0195).""" @pytest.mark.parametrize( "cmd", @@ -149,15 +149,49 @@ class TestReadOnlyBlocked: "git status", "git log --oneline", "git diff", + "git show HEAD", + "git ls-files", + "git ls-tree HEAD", + "git rev-parse --show-toplevel", + "git blame README.md", + "git grep TODO", + "git archive HEAD", + "git for-each-ref", + "git -C /tmp/x log", + ], + ) + def test_allows_read_verbs(self, cmd): + """Allowlisted read verbs are not blocked — raw is fine.""" + assert not _is_blocked(_bash(cmd)), f"Read verb should be allowed: {cmd}" + + +class TestNonAllowlistedGitBlocked: + """Git verbs outside the read allowlist stay blocked — conservative.""" + + @pytest.mark.parametrize( + "cmd", + [ "git fetch", "git branch", "git tag", "git remote -v", ], ) - def test_blocks_read_only_raw_git(self, cmd): - """Read-only raw git is also blocked — use drone instead.""" - assert _is_blocked(_bash(cmd)), f"Should block raw git (use drone): {cmd}" + def test_blocks_non_allowlisted(self, cmd): + """Reads not on the allowlist (fetch/branch/tag/remote) still block.""" + assert _is_blocked(_bash(cmd)), f"Should block (use drone): {cmd}" + + @pytest.mark.parametrize( + "cmd", + [ + "git log && git push", + "git status; git commit -m x", + "git diff | git apply", + ], + ) + def test_blocks_chained_read_then_write(self, cmd): + """A read chained with a write blocks the whole command.""" + assert _is_blocked(_bash(cmd)), f"Chained read+write should block: {cmd}" class TestDroneNotBlocked: diff --git a/src/aipass/hooks/README.md b/src/aipass/hooks/README.md index b108928c..befbd313 100644 --- a/src/aipass/hooks/README.md +++ b/src/aipass/hooks/README.md @@ -78,7 +78,7 @@ src/aipass/hooks/ │ └── diagnostics.py # JSONL logging for hook execution ├── logs/ │ └── engine.jsonl # JSONL diagnostics (every hook execution) -├── tests/ # 314 tests across 20 test files +├── tests/ # 385 tests across 20 test files └── STATUS.local.md ``` diff --git a/src/aipass/hooks/apps/handlers/security/git_gate.py b/src/aipass/hooks/apps/handlers/security/git_gate.py index f4f9a74b..f2643398 100644 --- a/src/aipass/hooks/apps/handlers/security/git_gate.py +++ b/src/aipass/hooks/apps/handlers/security/git_gate.py @@ -24,6 +24,34 @@ RAW_GH_RE = re.compile(r"(? bool: return False +def _split_clauses(cmd: str) -> list[str]: + """Split on compound operators and subshell boundaries.""" + parts = re.split(r"&&|\|\||[;|]", cmd) + clauses: list[str] = [] + for part in parts: + clauses.extend(re.split(r"[$()`]", part)) + return clauses + + +def _extract_git_verb(tokens: list[str]) -> str | None: + """Extract the git subcommand verb, skipping global options.""" + i = 0 + while i < len(tokens): + tok = tokens[i] + if not tok.startswith("-"): + return tok + if tok in _GIT_OPTS_WITH_ARG: + i += 2 + continue + i += 1 + return None + + +def _all_git_reads(scan: str) -> bool: + """Return True only if every git invocation in scan is a read-only verb.""" + found_any = False + for clause in _split_clauses(scan): + for m in RAW_GIT_RE.finditer(clause): + found_any = True + after = clause[m.end() :].split() + verb = _extract_git_verb(after) + if verb is None or verb not in READ_ALLOWED_GIT_SUBCOMMANDS: + return False + return found_any + + def _block(reason: str) -> dict: return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2} @@ -80,7 +142,7 @@ def _check_bash(tool_input: dict) -> dict: return _BLOCK_ALLOW scan = re.sub(r'"(?:[^"\\]|\\.)*"', '""', cmd) scan = re.sub(r"'(?:[^'\\]|\\.)*'", "''", scan) - if RAW_GIT_RE.search(scan): + if RAW_GIT_RE.search(scan) and not _all_git_reads(scan): return _block(GIT_GH_REDIRECT) if RAW_GH_RE.search(scan) and not _is_allowed_gh(cmd): return _block(GIT_GH_REDIRECT) diff --git a/src/aipass/hooks/tests/test_git_gate.py b/src/aipass/hooks/tests/test_git_gate.py index e1598cd2..9271beae 100644 --- a/src/aipass/hooks/tests/test_git_gate.py +++ b/src/aipass/hooks/tests/test_git_gate.py @@ -1,69 +1,287 @@ # =================== AIPass ==================== # Name: test_git_gate.py -# Version: 1.0.0 +# Version: 2.0.0 # Description: Tests for git_gate security handler # Branch: hooks # Created: 2026-05-21 -# Modified: 2026-05-21 +# Modified: 2026-06-05 # ============================================= """Tests for handlers/security/git_gate.py.""" import json +CWD = "/home/patrick/Projects/AIPass/src/aipass/api" -class TestGitGateHandler: - def test_block_raw_git(self): - from aipass.hooks.apps.handlers.security.git_gate import handle - result = handle( - { - "tool_name": "Bash", - "tool_input": {"command": "git status"}, - "cwd": "/home/patrick/Projects/AIPass/src/aipass/api", - } - ) - assert result["exit_code"] == 2 - parsed = json.loads(result["stdout"]) - assert parsed["decision"] == "block" - assert "drone" in parsed["reason"] +def _bash(cmd: str) -> dict: + from aipass.hooks.apps.handlers.security.git_gate import handle + + return handle({"tool_name": "Bash", "tool_input": {"command": cmd}, "cwd": CWD}) + + +def _assert_allowed(result: dict) -> None: + assert result["exit_code"] == 0 + assert result["stdout"] == "" + + +def _assert_blocked(result: dict) -> None: + assert result["exit_code"] == 2 + parsed = json.loads(result["stdout"]) + assert parsed["decision"] == "block" + + +class TestGitGateReadAllowed: + """Read-only git verbs are allowed raw.""" + + def test_git_status(self): + _assert_allowed(_bash("git status")) + + def test_git_log(self): + _assert_allowed(_bash("git log --oneline -10")) + + def test_git_diff(self): + _assert_allowed(_bash("git diff HEAD~1")) + + def test_git_show(self): + _assert_allowed(_bash("git show HEAD:README.md")) + + def test_git_ls_files(self): + _assert_allowed(_bash("git ls-files")) + + def test_git_ls_tree(self): + _assert_allowed(_bash("git ls-tree HEAD")) + + def test_git_cat_file(self): + _assert_allowed(_bash("git cat-file -p HEAD")) + + def test_git_rev_parse(self): + _assert_allowed(_bash("git rev-parse HEAD")) + + def test_git_rev_list(self): + _assert_allowed(_bash("git rev-list --count HEAD")) + + def test_git_blame(self): + _assert_allowed(_bash("git blame README.md")) + + def test_git_describe(self): + _assert_allowed(_bash("git describe --tags")) + + def test_git_for_each_ref(self): + _assert_allowed(_bash("git for-each-ref refs/heads")) + + def test_git_show_ref(self): + _assert_allowed(_bash("git show-ref --heads")) + + def test_git_symbolic_ref(self): + _assert_allowed(_bash("git symbolic-ref HEAD")) + + def test_git_shortlog(self): + _assert_allowed(_bash("git shortlog -sn")) + + def test_git_grep(self): + _assert_allowed(_bash("git grep TODO")) + + def test_git_archive(self): + _assert_allowed(_bash("git archive HEAD")) + + def test_git_archive_with_args(self): + _assert_allowed(_bash("git archive --format=tar HEAD")) + + def test_git_count_objects(self): + _assert_allowed(_bash("git count-objects -v")) + + def test_git_var(self): + _assert_allowed(_bash("git var GIT_EDITOR")) + + def test_git_help(self): + _assert_allowed(_bash("git help status")) + + def test_git_version(self): + _assert_allowed(_bash("git version")) + + +class TestGitGateGlobalOptions: + """Read verbs with global options before the subcommand.""" + + def test_git_C_path_ls_files(self): + _assert_allowed(_bash("git -C /some/path ls-files")) + + def test_git_C_path_push_blocked(self): + _assert_blocked(_bash("git -C /some/path push")) + + def test_git_no_pager_log(self): + _assert_allowed(_bash("git --no-pager log")) + + def test_git_paginate_diff(self): + _assert_allowed(_bash("git --paginate diff")) + + def test_git_c_config_status(self): + _assert_allowed(_bash("git -c core.pager=less status")) + + def test_git_git_dir_log(self): + _assert_allowed(_bash("git --git-dir=/foo/.git log")) + + def test_git_work_tree_status(self): + _assert_allowed(_bash("git --work-tree /foo status")) + + def test_git_multiple_opts_ls_files(self): + _assert_allowed(_bash("git -C /foo -c key=val --no-pager ls-files")) + + def test_git_multiple_opts_push_blocked(self): + _assert_blocked(_bash("git -C /foo -c key=val --no-pager push")) + + +class TestGitGateWriteBlocked: + """Write/ambiguous git verbs are blocked.""" + + def test_git_push(self): + _assert_blocked(_bash("git push")) + + def test_git_commit(self): + _assert_blocked(_bash("git commit -m 'msg'")) + + def test_git_checkout(self): + _assert_blocked(_bash("git checkout main")) + + def test_git_switch(self): + _assert_blocked(_bash("git switch main")) + + def test_git_merge(self): + _assert_blocked(_bash("git merge feature")) + + def test_git_rebase(self): + _assert_blocked(_bash("git rebase main")) + + def test_git_reset(self): + _assert_blocked(_bash("git reset --hard HEAD")) + + def test_git_clone(self): + _assert_blocked(_bash("git clone https://example.com/repo")) + + def test_git_pull(self): + _assert_blocked(_bash("git pull")) + + def test_git_fetch(self): + _assert_blocked(_bash("git fetch origin")) + + def test_git_clean(self): + _assert_blocked(_bash("git clean -fd")) + + def test_git_stash(self): + _assert_blocked(_bash("git stash")) + + def test_git_cherry_pick(self): + _assert_blocked(_bash("git cherry-pick abc123")) + + def test_git_revert(self): + _assert_blocked(_bash("git revert HEAD")) + + def test_git_rm(self): + _assert_blocked(_bash("git rm file.py")) + + def test_git_mv(self): + _assert_blocked(_bash("git mv old.py new.py")) + + def test_git_init(self): + _assert_blocked(_bash("git init")) + + def test_git_restore(self): + _assert_blocked(_bash("git restore file.py")) + + def test_git_add(self): + _assert_blocked(_bash("git add .")) + + def test_git_tag(self): + _assert_blocked(_bash("git tag v1.0")) + + def test_git_branch(self): + _assert_blocked(_bash("git branch -D main")) + + def test_git_worktree(self): + _assert_blocked(_bash("git worktree add ../tmp")) + + def test_git_gc(self): + _assert_blocked(_bash("git gc")) + + def test_git_prune(self): + _assert_blocked(_bash("git prune")) + + def test_git_am(self): + _assert_blocked(_bash("git am patch.mbox")) + + def test_git_apply(self): + _assert_blocked(_bash("git apply patch.diff")) + + def test_bare_git(self): + _assert_blocked(_bash("git ")) + + +class TestGitGateChaining: + """Compound commands with mixed git verbs.""" + + def test_chained_read_then_write_blocked(self): + _assert_blocked(_bash("git ls-files && git push")) + + def test_chained_reads_allowed(self): + _assert_allowed(_bash("git ls-files && git log")) + + def test_piped_read_write_blocked(self): + _assert_blocked(_bash("git ls-files | git push")) + + def test_semicolon_read_write_blocked(self): + _assert_blocked(_bash("git status; git commit -m 'msg'")) + + def test_or_read_write_blocked(self): + _assert_blocked(_bash("git status || git push")) + + def test_read_with_non_git_allowed(self): + _assert_allowed(_bash("git ls-files && echo done")) + + def test_non_git_then_read_allowed(self): + _assert_allowed(_bash("echo start && git status")) + + def test_three_reads_allowed(self): + _assert_allowed(_bash("git status && git log && git diff")) + + def test_two_reads_one_write_blocked(self): + _assert_blocked(_bash("git status && git log && git push")) + + +class TestGitGateWordBoundary: + """Word-boundary and quote handling.""" + + def test_gitfoo_not_matched(self): + _assert_allowed(_bash("gitfoo status")) + + def test_git_in_quoted_string(self): + _assert_allowed(_bash('echo "git push"')) + + def test_git_in_single_quoted_string(self): + _assert_allowed(_bash("echo 'git push'")) + + def test_drone_git_allowed(self): + _assert_allowed(_bash("drone @git status")) + + def test_path_git_not_matched(self): + _assert_allowed(_bash("/usr/bin/git push")) + + def test_dotgit_not_matched(self): + _assert_allowed(_bash("cat .git/config")) + + +class TestGitGateGhCommands: + """gh command handling (unchanged behavior).""" def test_block_raw_gh(self): - from aipass.hooks.apps.handlers.security.git_gate import handle - - result = handle( - { - "tool_name": "Bash", - "tool_input": {"command": "gh pr list"}, - "cwd": "/home/patrick/Projects/AIPass/src/aipass/api", - } - ) - assert result["exit_code"] == 2 - - def test_allow_drone_git(self): - from aipass.hooks.apps.handlers.security.git_gate import handle - - result = handle( - { - "tool_name": "Bash", - "tool_input": {"command": "drone @git status"}, - "cwd": "/home/patrick/Projects/AIPass/src/aipass/api", - } - ) - assert result["exit_code"] == 0 - assert result["stdout"] == "" + _assert_blocked(_bash("gh pr list")) def test_allow_gh_api(self): - from aipass.hooks.apps.handlers.security.git_gate import handle + _assert_allowed(_bash("gh api repos/owner/repo/pulls")) - result = handle( - { - "tool_name": "Bash", - "tool_input": {"command": "gh api repos/owner/repo/pulls"}, - "cwd": "/home/patrick/Projects/AIPass/src/aipass/api", - } - ) - assert result["exit_code"] == 0 + +class TestGitGateEditProtection: + """Protected file edit handling.""" def test_block_edit_settings(self): from aipass.hooks.apps.handlers.security.git_gate import handle @@ -72,12 +290,10 @@ class TestGitGateHandler: { "tool_name": "Edit", "tool_input": {"file_path": "/home/patrick/.claude/settings.json"}, - "cwd": "/home/patrick/Projects/AIPass/src/aipass/api", + "cwd": CWD, } ) - assert result["exit_code"] == 2 - parsed = json.loads(result["stdout"]) - assert parsed["decision"] == "block" + _assert_blocked(result) def test_allow_edit_settings_from_devpulse(self): from aipass.hooks.apps.handlers.security.git_gate import handle @@ -89,7 +305,7 @@ class TestGitGateHandler: "cwd": "/home/patrick/Projects/AIPass/src/aipass/devpulse", } ) - assert result["exit_code"] == 0 + _assert_allowed(result) def test_block_edit_hooks_dir(self): from aipass.hooks.apps.handlers.security.git_gate import handle @@ -98,38 +314,25 @@ class TestGitGateHandler: { "tool_name": "Edit", "tool_input": {"file_path": "/home/patrick/Projects/AIPass/.claude/hooks/some_hook.py"}, - "cwd": "/home/patrick/Projects/AIPass/src/aipass/api", + "cwd": CWD, } ) - assert result["exit_code"] == 2 + _assert_blocked(result) + + +class TestGitGateMisc: + """Miscellaneous edge cases.""" def test_allow_normal_bash(self): - from aipass.hooks.apps.handlers.security.git_gate import handle - - result = handle( - { - "tool_name": "Bash", - "tool_input": {"command": "ls -la"}, - "cwd": "/home/patrick/Projects/AIPass/src/aipass/api", - } - ) - assert result["exit_code"] == 0 - assert result["stdout"] == "" - - def test_git_in_quoted_string(self): - from aipass.hooks.apps.handlers.security.git_gate import handle - - result = handle( - { - "tool_name": "Bash", - "tool_input": {"command": 'echo "git status"'}, - "cwd": "/home/patrick/Projects/AIPass/src/aipass/api", - } - ) - assert result["exit_code"] == 0 + _assert_allowed(_bash("ls -la")) def test_empty_hook_data(self): from aipass.hooks.apps.handlers.security.git_gate import handle result = handle({}) assert result["exit_code"] == 0 + + def test_block_message_mentions_read_verbs(self): + result = _bash("git push") + parsed = json.loads(result["stdout"]) + assert "Read-only verbs" in parsed["reason"] diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/architecture_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/architecture_check.py index afd1eb0a..38976c77 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/architecture_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/architecture_check.py @@ -443,8 +443,13 @@ def check_template_baseline(module_path: str, bypass_rules: list | None = None) branch_name = branch_path.name # Read citizen class from passport + # .trinity/ is gitignored — absent in clean checkouts / CI. + # Skip the template baseline check entirely when passport is unavailable. citizen_class = _get_citizen_class(branch_path) if not citizen_class: + passport_path = branch_path / ".trinity" / "passport.json" + if not passport_path.exists(): + return [] return [ { "name": "Template baseline", diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/encapsulation_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/encapsulation_check.py index 5ee62e43..2104ad0c 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/encapsulation_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/encapsulation_check.py @@ -39,18 +39,40 @@ def _find_registry() -> Path: return Path.cwd() / "AIPASS_REGISTRY.json" +def _infer_branch_from_path(file_path: str) -> Optional[Dict]: + """Infer branch info from filesystem path when registry is unavailable. + + Looks for the ``src/aipass/{branch}/apps/`` or ``{branch}/apps/`` pattern + and returns a minimal branch dict with name and path. + """ + resolved = Path(file_path).resolve() + parts = resolved.parts + for i, part in enumerate(parts): + if part == "apps" and i >= 1: + candidate = Path(*parts[:i]) + branch_name = parts[i - 1] + if branch_name == "src": + continue + return {"name": branch_name, "path": str(candidate)} + return None + + def get_branch_from_path(file_path: str) -> Optional[Dict]: - """Detect which branch a file belongs to using AIPASS_REGISTRY.json.""" + """Detect which branch a file belongs to using AIPASS_REGISTRY.json. + + Falls back to path-based inference when the registry is unavailable + (e.g. in CI clean-checkout environments where the registry is gitignored). + """ try: registry_path = _find_registry() if not registry_path.exists(): - return None + return _infer_branch_from_path(file_path) with open(registry_path, "r", encoding="utf-8") as f: registry = json.load(f) if not registry: - return None + return _infer_branch_from_path(file_path) registry_dir = registry_path.parent resolved_path = str(Path(file_path).resolve()) @@ -67,10 +89,10 @@ def get_branch_from_path(file_path: str) -> Optional[Dict]: if resolved_path.startswith(branch_path_str + "/") or resolved_path == branch_path_str: return branch - return None + return _infer_branch_from_path(file_path) except Exception: logger.info("Cannot determine branch for path: %s", file_path) - return None + return _infer_branch_from_path(file_path) def extract_branch_from_import(import_line: str) -> Optional[str]: @@ -531,7 +553,9 @@ def check_cross_package_imports( return { "name": "Cross-package handler imports", "passed": False, - "message": f"Line {first['line']}: handlers.{first['from_package']} imported from handlers.{first['to_package']}", + "message": ( + f"Line {first['line']}: handlers.{first['from_package']} imported from handlers.{first['to_package']}" + ), } return { diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/log_structure_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/log_structure_check.py index 6d5b78ae..1c6a6c98 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/log_structure_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/log_structure_check.py @@ -87,18 +87,20 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: } # Check 1: Branch-root log placement — logs/ directory at the branch root + # logs/ is gitignored (runtime artifact, created on first log write). + # Only check when the directory actually exists; absence in a clean + # checkout or CI environment is expected and not a violation. branch_root = _find_branch_root(path) logs_dir = branch_root / "logs" has_logs_dir = logs_dir.is_dir() - checks.append( - { - "name": "Branch-root logs/ directory", - "passed": has_logs_dir, - "message": f"logs/ directory exists at branch root {branch_root}/" - if has_logs_dir - else f"Missing logs/ directory at branch root {branch_root}/ — two-tier model requires logs/ at branch root", - } - ) + if has_logs_dir: + checks.append( + { + "name": "Branch-root logs/ directory", + "passed": True, + "message": f"logs/ directory exists at branch root {branch_root}/", + } + ) # Check 2-3: Scan file for hardcoded log paths try: diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py index c81b0e60..549dd014 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py @@ -36,6 +36,49 @@ from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed AUDIT_SCOPE = "entry_point" +def _is_gitignored(path: Path) -> bool: + """Check if a path is covered by .gitignore rules. + + Uses ``git check-ignore`` from the repo root (discovered via + ``git rev-parse --show-toplevel``). Falls back to a built-in list + of known AIPass gitignored patterns when git is unavailable. + """ + try: + top = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + timeout=5, + ) + if top.returncode == 0: + repo_root = top.stdout.strip() + result = subprocess.run( + ["git", "-C", repo_root, "check-ignore", "-q", str(path)], + capture_output=True, + timeout=5, + ) + return result.returncode == 0 + except Exception: + logger.info("git check-ignore unavailable for %s", path) + + name = path.name + known_ignored = { + "logs", + "artifacts", + "dropbox", + "system_logs", + "docs.local", + ".trinity", + } + if name in known_ignored: + return True + if name.endswith("_json"): + return True + if name in ("STATUS.local.md", "DASHBOARD.local.json"): + return True + return False + + def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: """ Check if branch README follows standards @@ -341,6 +384,8 @@ def check_directory_tree(lines: List[str], branch_root: Path, file_path: str, by found = True break if not found: + if _is_gitignored(branch_root / dir_name): + continue missing_dirs.append(dir_name) if not missing_dirs: @@ -542,6 +587,8 @@ def check_markdown_links(lines: List[str], branch_root: Path, file_path: str, by for link_text, link_path in links: resolved = (branch_root / link_path).resolve() if not resolved.exists(): + if _is_gitignored(branch_root / link_path): + continue dead_links.append(f"{link_path} ({link_text})") if not dead_links: diff --git a/src/aipass/seedgo/tests/test_checkers_batch6.py b/src/aipass/seedgo/tests/test_checkers_batch6.py index 52e0552c..11d35485 100644 --- a/src/aipass/seedgo/tests/test_checkers_batch6.py +++ b/src/aipass/seedgo/tests/test_checkers_batch6.py @@ -325,8 +325,8 @@ class TestCheckTemplateBaseline: assert result[0]["passed"] is False assert "Could not detect branch path" in result[0]["message"] - def test_missing_citizen_class(self, tmp_path): - """Branch without passport.json fails the citizen_class check.""" + def test_missing_passport_skips(self, tmp_path): + """Branch without passport.json skips template baseline (gitignored).""" from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( check_template_baseline, ) @@ -336,6 +336,24 @@ class TestCheckTemplateBaseline: entry = apps_dir / "mybranch.py" entry.write_text('"""Entry."""\n', encoding="utf-8") + result = check_template_baseline(str(entry)) + assert result == [] + + def test_passport_without_citizen_class(self, tmp_path): + """Branch with passport.json but no citizen_class fails.""" + from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( + check_template_baseline, + ) + + apps_dir = tmp_path / "mybranch" / "apps" + apps_dir.mkdir(parents=True) + entry = apps_dir / "mybranch.py" + entry.write_text('"""Entry."""\n', encoding="utf-8") + trinity = tmp_path / "mybranch" / ".trinity" + trinity.mkdir() + passport = trinity / "passport.json" + passport.write_text('{"identity": {}}', encoding="utf-8") + result = check_template_baseline(str(entry)) assert len(result) >= 1 assert result[0]["passed"] is False diff --git a/src/aipass/seedgo/tests/test_coverage_arch_checklist.py b/src/aipass/seedgo/tests/test_coverage_arch_checklist.py index a35ae38e..8978eeaa 100644 --- a/src/aipass/seedgo/tests/test_coverage_arch_checklist.py +++ b/src/aipass/seedgo/tests/test_coverage_arch_checklist.py @@ -278,7 +278,7 @@ class TestCheckModule: assert "3-layer pattern" not in check_names def test_entry_point_primary_triggers_template_baseline(self, tmp_path): - """Primary entry point (branch.py matching branch dir name) triggers template baseline.""" + """Primary entry point with passport triggers template baseline.""" from aipass.seedgo.apps.handlers.aipass_standards.architecture_check import ( check_module, ) @@ -288,7 +288,9 @@ class TestCheckModule: apps.mkdir(parents=True) entry = apps / "mybranch.py" entry.write_text("# entry\n", encoding="utf-8") - # No passport -> template baseline will fail at citizen_class step + trinity = branch / ".trinity" + trinity.mkdir() + (trinity / "passport.json").write_text('{"identity": {}}', encoding="utf-8") result = check_module(str(entry)) check_names = [c["name"] for c in result["checks"]] assert any("Template baseline" in n or "citizen_class" in str(c) for n, c in zip(check_names, result["checks"]))