* refactor: rename /pipeline-driver to /run-pipeline + /spec-driver to /spec - Rename command + skill dir + skill() ref for both - Update cross-references in .opencode/ .md files - pipeline_status / spec_status tool names unchanged (independent) - Update project-map README * docs(handoff): add pr-14 handoff + ADR-009 * docs(handoff): set PR number 29 --------- Co-authored-by: opencode-agent <agent@slaid098.dev>
186 lines
6.9 KiB
Python
Executable file
186 lines
6.9 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Check agent permission configs for dangerous allow rules."""
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
AGENTS_DIR = REPO_ROOT / "config" / "agents"
|
|
OPENCODE_JSON = REPO_ROOT / "config" / "opencode.json"
|
|
|
|
DANGEROUS_PATTERNS = [
|
|
(r"^gh api\*?$", "broad — allows gh api -X DELETE/PUT/POST"),
|
|
(r"^gh api \*$", "broad — allows gh api -X DELETE/PUT/POST"),
|
|
(r"^gh api -X (DELETE|PUT|POST|PATCH)\*?$", "destructive HTTP method"),
|
|
(r"^gh api --method (DELETE|PUT|POST|PATCH)\*?$", "destructive HTTP method"),
|
|
(
|
|
r"^gh pr checks\*?$",
|
|
"uses GraphQL statusCheckRollup → Checks API → 403 on fine-grained PAT "
|
|
"(scope 'Checks: read' does not exist). Use 'gh run list'/'gh run view' or "
|
|
"'gh api repos/.../actions/runs' (actions=read scope). See ADR-005.",
|
|
),
|
|
(
|
|
r"^gh pr view \*--json statusCheckRollup\*?$",
|
|
"GraphQL statusCheckRollup → Checks API → 403 on fine-grained PAT. "
|
|
"Use 'gh pr view --json headRefName,body,title' (metadata only) or "
|
|
"'gh run list' for CI status. See ADR-005.",
|
|
),
|
|
(
|
|
r"^gh pr merge\*?$",
|
|
"merge is done by main agent via run-pipeline, not subagents. "
|
|
"Use 'gh pr merge' from primary build/plan agent only (global "
|
|
"opencode.json:195 has 'gh pr merge*: allow'). Per-agent 'gh pr "
|
|
"merge*: deny' is the defense (see ADR-016). See ADR-006 for "
|
|
"DANGEROUS_PATTERNS mechanism.",
|
|
"agent",
|
|
),
|
|
(
|
|
r"^python3? .*pipeline-status\.py",
|
|
"use pipeline_status tool, not bash. See ADR-019",
|
|
"agent",
|
|
),
|
|
(
|
|
r"^python .*pipeline-status\.py",
|
|
"use pipeline_status tool, not bash. See ADR-019",
|
|
"agent",
|
|
),
|
|
(
|
|
r"^python3? .*spec-status\.py",
|
|
"use spec_status tool, not bash. See ADR-NNN",
|
|
"agent",
|
|
),
|
|
(
|
|
r"^python .*spec-status\.py",
|
|
"use spec_status tool, not bash. See ADR-NNN",
|
|
"agent",
|
|
),
|
|
(r"^gh repo\*?$", "broad — allows gh repo delete"),
|
|
(r"^gh repo \*$", "broad — allows gh repo delete"),
|
|
(r"^gh repo (delete|edit|rename|archive|unarchive|sync)\*?$", "destructive repo operation"),
|
|
(r"^git -C \*$", "broad — allows any git command in any dir"),
|
|
(r"^git reset \*", "destroys uncommitted changes"),
|
|
(r"^git clean \*", "deletes untracked files"),
|
|
(r"^git push --force\*", "rewrites remote history"),
|
|
(r"^git push --delete\*", "deletes remote branches"),
|
|
(r"^git push -f\*", "rewrites remote history"),
|
|
(r"^git push origin --delete\*", "deletes remote branches"),
|
|
(r"^rm -rf", "recursive force delete"),
|
|
(r"^rm -f /", "force delete system files"),
|
|
(r"^rm /", "delete system files"),
|
|
(r"^rmdir \*", "delete directories"),
|
|
(r"^shred \*", "secure delete"),
|
|
(r"^sudo\*?$", "privilege escalation"),
|
|
(r"^sudo \*", "privilege escalation"),
|
|
(r"^chmod \*", "change permissions"),
|
|
(r"^chown \*", "change ownership"),
|
|
(r"^mkfs\*", "format filesystem"),
|
|
(r"^dd \*", "raw disk write"),
|
|
(r"^fdisk\*", "modify partitions"),
|
|
(r"^shutdown\*", "shutdown system"),
|
|
(r"^reboot\*", "reboot system"),
|
|
(r"^halt\*", "halt system"),
|
|
(r"^poweroff\*", "power off system"),
|
|
(r"^docker system prune\*", "prune everything"),
|
|
(r"^docker rm \*", "remove containers"),
|
|
(r"^docker rmi \*", "remove images"),
|
|
(r"^docker volume rm \*", "remove volumes"),
|
|
(r"^docker network rm \*", "remove networks"),
|
|
(r"^ssh \* mkfs\*", "format remote disk"),
|
|
(r"^ssh \* dd \*", "raw disk write on remote"),
|
|
(r"^ssh \* fdisk\*", "modify partitions on remote"),
|
|
(r"^ssh \* reboot\*", "reboot remote"),
|
|
(r"^ssh \* shutdown\*", "shutdown remote"),
|
|
(r"^ssh \* halt\*", "halt remote"),
|
|
(r"^ssh \* poweroff\*", "power off remote"),
|
|
(r"^ssh \* rm \*", "delete files on remote"),
|
|
(r"^ssh \* chmod\*", "change permissions on remote"),
|
|
(r"^ssh \* chown\*", "change ownership on remote"),
|
|
(r"^kubectl delete \*", "delete k8s resources"),
|
|
(r"^kubectl scale \*", "scale k8s resources"),
|
|
(r"^kill -9 \*", "force kill processes"),
|
|
(r"^killall\*", "kill processes by name"),
|
|
(r"^pkill\*", "kill processes by pattern"),
|
|
(r"^npm install\*", "install arbitrary packages"),
|
|
(r"^npm i \*", "install arbitrary packages"),
|
|
(r"^npm add\*", "install arbitrary packages"),
|
|
(r"^pip install\*", "install arbitrary packages"),
|
|
(r"^uv pip install\*", "install arbitrary packages"),
|
|
(r"^uv add\*", "install arbitrary packages"),
|
|
(r"^cargo install\*", "install arbitrary packages"),
|
|
(r"^gem install\*", "install arbitrary packages"),
|
|
]
|
|
|
|
|
|
def parse_agent_bash_rules(filepath: Path) -> dict[str, str]:
|
|
content = filepath.read_text()
|
|
parts = content.split("---", 2)
|
|
if len(parts) < 3:
|
|
return {}
|
|
frontmatter = parts[1]
|
|
lines = frontmatter.split("\n")
|
|
rules: dict[str, str] = {}
|
|
in_bash = False
|
|
for line in lines:
|
|
if line.strip() == "bash:":
|
|
in_bash = True
|
|
continue
|
|
if in_bash:
|
|
if line.startswith(" "):
|
|
match = re.match(r'\s*"([^"]+)":\s*(\w+)', line)
|
|
if match:
|
|
rules[match.group(1)] = match.group(2)
|
|
elif line.strip() and not line.startswith(" "):
|
|
break
|
|
return rules
|
|
|
|
|
|
def parse_global_bash_rules(filepath: Path) -> dict[str, str]:
|
|
with open(filepath) as f:
|
|
config = json.load(f)
|
|
return config.get("permission", {}).get("bash", {})
|
|
|
|
|
|
def check_rules(rules: dict[str, str], source: str) -> list[str]:
|
|
is_global = source == "opencode.json"
|
|
violations = []
|
|
for pattern, action in rules.items():
|
|
if action != "allow":
|
|
continue
|
|
for entry in DANGEROUS_PATTERNS:
|
|
regex, reason = entry[0], entry[1]
|
|
scope = entry[2] if len(entry) > 2 else "all"
|
|
if scope == "agent" and is_global:
|
|
continue
|
|
if re.match(regex, pattern):
|
|
violations.append(f' [{source}] "{pattern}": {action}\n -> {reason}')
|
|
break
|
|
return violations
|
|
|
|
|
|
def main() -> None:
|
|
all_violations: list[str] = []
|
|
|
|
if OPENCODE_JSON.exists():
|
|
rules = parse_global_bash_rules(OPENCODE_JSON)
|
|
all_violations.extend(check_rules(rules, "opencode.json"))
|
|
|
|
if AGENTS_DIR.exists():
|
|
for agent_file in sorted(AGENTS_DIR.glob("*.md")):
|
|
rules = parse_agent_bash_rules(agent_file)
|
|
all_violations.extend(check_rules(rules, f"agents/{agent_file.name}"))
|
|
|
|
if not all_violations:
|
|
print("OK: No dangerous permission rules found.")
|
|
sys.exit(0)
|
|
|
|
print("FAIL: Dangerous permission rules detected:\n")
|
|
for v in all_violations:
|
|
print(v)
|
|
print(f"\nTotal: {len(all_violations)} violation(s)")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|