fix(seedgo): audit committed source not working tree — seedgo-audit CI gate green (DPLAN-0195)

Four standards checkers validated the working tree, so CI (clean checkout =
tracked files only) scored ~97% while local audits passed at 100%. Fix each
to measure what git actually ships:

- log_structure: skip absent gitignored logs/ dir (keeps hardcoded-path checks)
- readme: cross-ref .gitignore (git check-ignore + fallback), skip ignored
  dirs/links in tree + dead-link checks
- encapsulation: infer branch from path when gitignored REGISTRY absent; fix
  aipass-branch collision
- architecture: skip cleanly when gitignored passport.json absent

Clean-tree AND working-tree audits both 13/13 = 100%. seedgo 1052 tests green,
pyright 0.

Also: git_gate read-verb allowlist (22 read verbs raw, write stays drone-gated);
update devpulse test_git_gate contract to match; devpulse local-prompt git
breadcrumb.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AIOSAI
2026-06-05 23:58:53 -07:00
co-authored by Claude Opus 4.8
parent 176f68439c
commit 24065f11b3
12 changed files with 538 additions and 110 deletions
+25
View File
@@ -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
@@ -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 <path>` (empty = ignored/untracked) or read `.gitignore` directly.
- Reproduce a clean checkout (tracked-only, like CI): `git archive HEAD | tar -x -C /tmp/<dir>` (`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.
+39 -5
View File
@@ -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:
+1 -1
View File
@@ -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
```
@@ -24,6 +24,34 @@ RAW_GH_RE = re.compile(r"(?<![@\w/.])gh\s")
GH_ALLOWED_SUBCOMMANDS = ("api",)
READ_ALLOWED_GIT_SUBCOMMANDS = frozenset(
{
"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",
}
)
_GIT_OPTS_WITH_ARG = frozenset({"-C", "-c", "--git-dir", "--work-tree", "--exec-path", "--namespace"})
BLOCKED_EDIT_PATTERNS = [
re.compile(r"/\.claude/settings(\.local)?\.json$"),
re.compile(r"/\.claude/hooks/"),
@@ -35,10 +63,8 @@ EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
TRUSTED_HOOK_EDITORS = ("devpulse", "seedgo")
GIT_GH_REDIRECT = (
"All git/gh commands are blocked. Use drone instead:\n"
" drone @git status # working tree status\n"
" drone @git diff # see changes\n"
" drone @git log # commit history\n"
"Write git commands are blocked. Read-only verbs (status, log, diff, show, etc.) are allowed raw.\n"
"For write operations, use drone:\n"
" drone @git smart-sync # fetch + rebase\n"
" drone @git sync # checkout main + pull\n"
" drone @git issue list # GitHub issues\n"
@@ -70,6 +96,42 @@ def _is_allowed_gh(cmd: str) -> 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)
+281 -78
View File
@@ -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"]
@@ -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",
@@ -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 {
@@ -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:
@@ -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:
@@ -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
@@ -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"]))