Merge pull request #511 from AIOSAI/work/system
feat(system): fix(system): DPLAN-0163 — git_gate hardening, standalone project fixes, 113-test suite
This commit is contained in:
@@ -22,8 +22,8 @@ from pathlib import Path
|
||||
|
||||
BLOCKED_GIT_VERBS = (
|
||||
"commit", "push", "pull", "merge", "rebase", "reset",
|
||||
"checkout", "switch", "branch", "cherry-pick", "revert",
|
||||
"rm", "mv", "restore", "clean", "config", "tag",
|
||||
"checkout", "switch", "cherry-pick", "revert",
|
||||
"rm", "mv", "restore", "clean", "config",
|
||||
)
|
||||
|
||||
BLOCKED_GIT_RE = re.compile(
|
||||
@@ -35,6 +35,18 @@ BLOCKED_GIT_STASH_RE = re.compile(
|
||||
r"(?<![@\w/.])git\s+stash\s+(drop|clear|pop|apply)\b"
|
||||
)
|
||||
|
||||
BLOCKED_GIT_BRANCH_RE = re.compile(
|
||||
r"(?<![@\w/.])git\s+branch\s+.*(-[dDmMcC]\b|--delete|--move|--copy|--force|--set-upstream-to|--unset-upstream)"
|
||||
)
|
||||
|
||||
BLOCKED_GIT_TAG_RE = re.compile(
|
||||
r"(?<![@\w/.])git\s+tag\s+.*(-d\b|--delete|--force|-f\b)"
|
||||
)
|
||||
|
||||
BLOCKED_GIT_REMOTE_RE = re.compile(
|
||||
r"(?<![@\w/.])git\s+remote\s+(add|remove|rename|set-url|set-branches|set-head|prune)\b"
|
||||
)
|
||||
|
||||
BLOCKED_GH_API_RE = re.compile(
|
||||
r"(?<![@\w/.])gh\s+api\b"
|
||||
)
|
||||
@@ -93,6 +105,22 @@ def _cwd_branch(cwd: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _is_project_owner(cwd: str) -> bool:
|
||||
"""Check if the current branch's passport has citizenship.owner: true."""
|
||||
p = Path(cwd)
|
||||
for d in [p] + list(p.parents):
|
||||
passport = d / ".trinity" / "passport.json"
|
||||
if passport.is_file():
|
||||
try:
|
||||
data = json.loads(passport.read_text(encoding="utf-8"))
|
||||
return bool(data.get("citizenship", {}).get("owner"))
|
||||
except Exception:
|
||||
return False
|
||||
if (d / ".git").exists():
|
||||
break
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
@@ -108,10 +136,13 @@ def main():
|
||||
# (PR descriptions, commit messages, examples in docs), not code to enforce.
|
||||
scan = re.sub(r'"(?:[^"\\]|\\.)*"', '""', cmd)
|
||||
scan = re.sub(r"'(?:[^'\\]|\\.)*'", "''", scan)
|
||||
if BLOCKED_GIT_STASH_RE.search(scan) or BLOCKED_GIT_RE.search(scan):
|
||||
if (BLOCKED_GIT_RE.search(scan) or BLOCKED_GIT_STASH_RE.search(scan)
|
||||
or BLOCKED_GIT_BRANCH_RE.search(scan) or BLOCKED_GIT_TAG_RE.search(scan)
|
||||
or BLOCKED_GIT_REMOTE_RE.search(scan)):
|
||||
_block(GIT_REDIRECT)
|
||||
if BLOCKED_GH_API_RE.search(scan) or BLOCKED_GH_RE.search(scan):
|
||||
_block(GH_REDIRECT)
|
||||
if not (_cwd_branch(cwd) in TRUSTED_HOOK_EDITORS or _is_project_owner(cwd)):
|
||||
_block(GH_REDIRECT)
|
||||
return
|
||||
|
||||
if tool_name in EDIT_TOOLS:
|
||||
|
||||
@@ -146,3 +146,4 @@ claude_4_7_transition_notes.md
|
||||
*.bak
|
||||
src/aipass/drone/apps/plugins/devpulse_ops/__pycache__/auth.cpython-312.pyc
|
||||
src/aipass/drone/apps/plugins/devpulse_ops/__pycache__/__init__.cpython-312.pyc
|
||||
.backup_system
|
||||
@@ -312,6 +312,16 @@ def global_prompt_md(name: str) -> str:
|
||||
"Projects use the registry.\n"
|
||||
"- **Memory** — update `.trinity/local.json` at session end. "
|
||||
"Memory is presence.\n"
|
||||
"\n"
|
||||
"## Maintenance\n"
|
||||
"\n"
|
||||
"- **Upgrade scaffold**: `drone @cli aipass init update` refreshes "
|
||||
"managed project files (hooks, prompts, settings) to latest templates.\n"
|
||||
"- **Entry point**: each agent's `apps/{name}.py` auto-configures "
|
||||
"`sys.path` and `AIPASS_BRANCH_NAME` env var. If prax logs to "
|
||||
"`unknown_branch/`, check that these are set.\n"
|
||||
"- **Standalone projects** use `src/{name}/` layout (not `src/aipass/{name}/`). "
|
||||
"Module discovery adapts automatically.\n"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ _spec.loader.exec_module(_mod)
|
||||
|
||||
BLOCKED_GIT_RE = _mod.BLOCKED_GIT_RE
|
||||
BLOCKED_GIT_STASH_RE = _mod.BLOCKED_GIT_STASH_RE
|
||||
BLOCKED_GIT_BRANCH_RE = _mod.BLOCKED_GIT_BRANCH_RE
|
||||
BLOCKED_GIT_TAG_RE = _mod.BLOCKED_GIT_TAG_RE
|
||||
BLOCKED_GIT_REMOTE_RE = _mod.BLOCKED_GIT_REMOTE_RE
|
||||
BLOCKED_GH_RE = _mod.BLOCKED_GH_RE
|
||||
BLOCKED_GH_API_RE = _mod.BLOCKED_GH_API_RE
|
||||
BLOCKED_EDIT_PATTERNS = _mod.BLOCKED_EDIT_PATTERNS
|
||||
@@ -44,7 +47,6 @@ class TestGitWriteBlocking:
|
||||
"git reset HEAD~1",
|
||||
"git checkout -b new-branch",
|
||||
"git switch -c new-branch",
|
||||
"git branch -D old",
|
||||
"git cherry-pick abc123",
|
||||
"git revert HEAD",
|
||||
"git rm file.txt",
|
||||
@@ -52,7 +54,6 @@ class TestGitWriteBlocking:
|
||||
"git restore --staged file.py",
|
||||
"git clean -fd",
|
||||
"git config user.name 'x'",
|
||||
"git tag v1.0",
|
||||
])
|
||||
def test_blocks_write_verbs(self, cmd):
|
||||
"""Each blocked git verb triggers the regex."""
|
||||
@@ -68,6 +69,44 @@ class TestGitWriteBlocking:
|
||||
"""Destructive stash subcommands are blocked."""
|
||||
assert BLOCKED_GIT_STASH_RE.search(cmd), f"Should block: {cmd}"
|
||||
|
||||
@pytest.mark.parametrize("cmd", [
|
||||
"git branch -D old",
|
||||
"git branch -d old",
|
||||
"git branch -m old new",
|
||||
"git branch -M old new",
|
||||
"git branch -c old new",
|
||||
"git branch --delete old",
|
||||
"git branch --move old new",
|
||||
"git branch --copy old new",
|
||||
"git branch --force old",
|
||||
"git branch --set-upstream-to=origin/main",
|
||||
"git branch --unset-upstream",
|
||||
])
|
||||
def test_blocks_branch_destructive(self, cmd):
|
||||
"""Destructive branch subcommands are blocked."""
|
||||
assert BLOCKED_GIT_BRANCH_RE.search(cmd), f"Should block: {cmd}"
|
||||
|
||||
@pytest.mark.parametrize("cmd", [
|
||||
"git tag -d v1.0",
|
||||
"git tag --delete v1.0",
|
||||
"git tag -f v1.0",
|
||||
"git tag --force v1.0",
|
||||
])
|
||||
def test_blocks_tag_destructive(self, cmd):
|
||||
"""Destructive tag operations are blocked."""
|
||||
assert BLOCKED_GIT_TAG_RE.search(cmd), f"Should block: {cmd}"
|
||||
|
||||
@pytest.mark.parametrize("cmd", [
|
||||
"git remote add origin url",
|
||||
"git remote remove origin",
|
||||
"git remote rename old new",
|
||||
"git remote set-url origin url",
|
||||
"git remote prune origin",
|
||||
])
|
||||
def test_blocks_remote_destructive(self, cmd):
|
||||
"""Destructive remote operations are blocked."""
|
||||
assert BLOCKED_GIT_REMOTE_RE.search(cmd), f"Should block: {cmd}"
|
||||
|
||||
|
||||
class TestLongFormFlagBypass:
|
||||
"""DPLAN-0163 Finding 1: long-form flags must not bypass detection."""
|
||||
@@ -135,11 +174,26 @@ class TestReadOnlyAllowed:
|
||||
"git remote -v",
|
||||
"git blame file.py",
|
||||
"git shortlog -sn",
|
||||
"git branch",
|
||||
"git branch -r",
|
||||
"git branch -a",
|
||||
"git branch --list",
|
||||
"git branch -v",
|
||||
"git branch --show-current",
|
||||
"git branch --contains abc123",
|
||||
"git tag",
|
||||
"git tag -l",
|
||||
"git tag --list",
|
||||
"git remote",
|
||||
"git remote show origin",
|
||||
])
|
||||
def test_allows_read_only(self, cmd):
|
||||
"""Read-only git subcommands must pass through."""
|
||||
assert not BLOCKED_GIT_RE.search(cmd), f"Should allow: {cmd}"
|
||||
assert not BLOCKED_GIT_STASH_RE.search(cmd), f"Should allow: {cmd}"
|
||||
assert not BLOCKED_GIT_BRANCH_RE.search(cmd), f"Should allow: {cmd}"
|
||||
assert not BLOCKED_GIT_TAG_RE.search(cmd), f"Should allow: {cmd}"
|
||||
assert not BLOCKED_GIT_REMOTE_RE.search(cmd), f"Should allow: {cmd}"
|
||||
|
||||
|
||||
class TestDroneNotBlocked:
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Backup System ignore patterns (gitignore-style)
|
||||
# Lines starting with # are comments. Blank lines are ignored.
|
||||
|
||||
# Backup system's own directory
|
||||
.backup_system/
|
||||
|
||||
# Version control
|
||||
.git/
|
||||
.svn/
|
||||
.hg/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
.tox/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -7,12 +7,19 @@ Auto-discovery architecture:
|
||||
- No manual imports or routing needed
|
||||
"""
|
||||
|
||||
import sys
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Any
|
||||
|
||||
from aipass.prax import logger
|
||||
PROJECT_ROOT = str(Path(__file__).resolve().parent.parent)
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
os.environ.setdefault("AIPASS_BRANCH_NAME", "{{BRANCH}}")
|
||||
|
||||
from aipass.prax import logger # noqa: E402
|
||||
|
||||
# =============================================================================
|
||||
# MODULE DISCOVERY
|
||||
@@ -21,6 +28,15 @@ from aipass.prax import logger
|
||||
MODULES_DIR = Path(__file__).parent / "modules"
|
||||
|
||||
|
||||
def _module_import_path(stem: str) -> str:
|
||||
"""Return the correct import path for a module, handling both layouts."""
|
||||
try:
|
||||
importlib.import_module(f"aipass.{{BRANCH}}.apps.modules.{stem}")
|
||||
return f"aipass.{{BRANCH}}.apps.modules.{stem}"
|
||||
except ImportError:
|
||||
return f"apps.modules.{stem}"
|
||||
|
||||
|
||||
def discover_modules() -> List[Any]:
|
||||
"""Auto-discover modules in modules/ directory."""
|
||||
modules = []
|
||||
@@ -32,7 +48,7 @@ def discover_modules() -> List[Any]:
|
||||
if file_path.name.startswith("_"):
|
||||
continue
|
||||
|
||||
module_name = f"aipass.{{BRANCH}}.apps.modules.{file_path.stem}"
|
||||
module_name = _module_import_path(file_path.stem)
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
|
||||
Reference in New Issue
Block a user