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:
co-authored by
Claude Opus 4.8
parent
176f68439c
commit
24065f11b3
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]))
|
||||
|
||||
Reference in New Issue
Block a user