From 2d84dca9413eeec8495bfa8c140da0c65a862acc Mon Sep 17 00:00:00 2001 From: jellespijker Date: Sat, 15 Aug 2026 14:03:58 +0200 Subject: [PATCH 1/3] [UC-3697] Add agent hooks and commit-time gates Add the hooks the agentic bootstrap generates, and the configuration that runs them. - .agents/hooks/ holds the gate scripts. - .pre-commit-config.yaml runs the gates. The two mutating hooks are limited to the files this change adds, so they cannot reformat the runner scripts this repository ships. - .aiignore is the one source for AI context exclusion. The compiler writes .ignore and .github/copilot-content-exclusion.yml. --- .agents/aiignore-generated.json | 109 ++++++ .agents/file-size-baseline.json | 8 + .agents/hooks.json | 52 +++ .agents/hooks/audit_quad_agent_parity.py | 368 +++++++++++++++++++ .agents/hooks/block-absolute-paths.py | 25 ++ .agents/hooks/block-secrets.py | 25 ++ .agents/hooks/block_name_matched_kill.py | 138 +++++++ .agents/hooks/check_atomic_bisect_history.py | 182 +++++++++ .agents/hooks/check_file_size_budget.py | 315 ++++++++++++++++ .agents/hooks/check_multi_intent_scope.py | 171 +++++++++ .agents/hooks/check_security_downgrades.py | 207 +++++++++++ .agents/hooks/check_upstream_alignment.py | 133 +++++++ .agents/hooks/compile_aiignore.py | 273 ++++++++++++++ .agents/hooks/compile_rule_frontmatter.py | 216 +++++++++++ .agents/hooks/copilot_index.py | 178 +++++++++ .agents/hooks/file_size_baseline.py | 105 ++++++ .agents/hooks/file_size_scope.py | 236 ++++++++++++ .agents/hooks/git-branch-guard.py | 112 ++++++ .agents/hooks/path_scanner.py | 197 ++++++++++ .agents/hooks/post-edit-linter.sh | 79 ++++ .agents/hooks/pretool_guard.py | 239 ++++++++++++ .agents/hooks/rule_advisories.py | 204 ++++++++++ .agents/hooks/run_adversarial_audit.py | 253 +++++++++++++ .agents/hooks/secret_scanner.py | 133 +++++++ .agents/hooks/suggest-skills.py | 121 ++++++ .agents/hooks/talisman_guard.py | 277 ++++++++++++++ .agents/hooks/verify_hooks_fire.py | 246 +++++++++++++ .aiignore | 81 ++++ .gitattributes | 1 + .github/copilot-content-exclusion.yml | 61 +++ .gitignore | 4 + .ignore | 83 +++++ .pre-commit-config.yaml | 133 +++++++ .talismanrc | 99 +++++ 34 files changed, 5064 insertions(+) create mode 100644 .agents/aiignore-generated.json create mode 100644 .agents/file-size-baseline.json create mode 100644 .agents/hooks.json create mode 100755 .agents/hooks/audit_quad_agent_parity.py create mode 100755 .agents/hooks/block-absolute-paths.py create mode 100755 .agents/hooks/block-secrets.py create mode 100755 .agents/hooks/block_name_matched_kill.py create mode 100755 .agents/hooks/check_atomic_bisect_history.py create mode 100755 .agents/hooks/check_file_size_budget.py create mode 100755 .agents/hooks/check_multi_intent_scope.py create mode 100755 .agents/hooks/check_security_downgrades.py create mode 100755 .agents/hooks/check_upstream_alignment.py create mode 100755 .agents/hooks/compile_aiignore.py create mode 100755 .agents/hooks/compile_rule_frontmatter.py create mode 100755 .agents/hooks/copilot_index.py create mode 100755 .agents/hooks/file_size_baseline.py create mode 100755 .agents/hooks/file_size_scope.py create mode 100755 .agents/hooks/git-branch-guard.py create mode 100755 .agents/hooks/path_scanner.py create mode 100755 .agents/hooks/post-edit-linter.sh create mode 100755 .agents/hooks/pretool_guard.py create mode 100755 .agents/hooks/rule_advisories.py create mode 100755 .agents/hooks/run_adversarial_audit.py create mode 100755 .agents/hooks/secret_scanner.py create mode 100755 .agents/hooks/suggest-skills.py create mode 100755 .agents/hooks/talisman_guard.py create mode 100755 .agents/hooks/verify_hooks_fire.py create mode 100644 .aiignore create mode 100644 .gitattributes create mode 100644 .github/copilot-content-exclusion.yml create mode 100644 .gitignore create mode 100644 .ignore create mode 100644 .pre-commit-config.yaml create mode 100644 .talismanrc diff --git a/.agents/aiignore-generated.json b/.agents/aiignore-generated.json new file mode 100644 index 0000000..4ad655e --- /dev/null +++ b/.agents/aiignore-generated.json @@ -0,0 +1,109 @@ +{ + "comment": "Records what compile_aiignore.py generated, so the next run replaces only its own entries and leaves hand-written rules alone. Do not edit.", + "claude_deny": [ + "Read(./**/*.pem)", + "Read(./**/*.key)", + "Read(./**/*.p12)", + "Read(./**/*.pfx)", + "Read(./**/*.jks)", + "Read(./**/*.keystore)", + "Read(./**/id_rsa)", + "Read(./**/id_ecdsa)", + "Read(./**/id_ed25519)", + "Read(./**/*.gpg)", + "Read(./**/*.asc)", + "Read(./**/.env)", + "Read(./**/.env.*)", + "Read(./**/.env-*)", + "Read(./**/.env_*)", + "Read(./**/*.tfvars)", + "Read(./**/.netrc)", + "Read(./**/.npmrc)", + "Read(./**/credentials.json)", + "Read(./**/service-account*.json)", + "Read(./**/secrets/**)", + "Read(./**/vendor/**)", + "Read(./**/third_party/**)", + "Read(./**/node_modules/**)", + "Read(./**/build/**)", + "Read(./**/dist/**)", + "Read(./**/out/**)", + "Read(./**/target/**)", + "Read(./**/.venv/**)", + "Read(./**/venv/**)", + "Read(./**/__pycache__/**)", + "Read(./**/*.o)", + "Read(./**/*.a)", + "Read(./**/*.so)", + "Read(./**/*.dylib)", + "Read(./**/*.dll)", + "Read(./**/*.exe)", + "Read(./**/.mypy_cache/**)", + "Read(./**/.pytest_cache/**)", + "Read(./**/.ruff_cache/**)", + "Read(./**/*.stl)", + "Read(./**/*.3mf)", + "Read(./**/*.gcode)", + "Read(./**/*.ufp)", + "Read(./**/*.obj)", + "Read(./**/*.step)", + "Read(./**/*.stp)", + "Read(./**/*.log)", + "Read(./**/logdump*/**)", + "Read(./**/customer-data/**)", + "Read(./**/pii/**)" + ], + "opencode_rules": [ + "**/*.3mf", + "**/*.a", + "**/*.asc", + "**/*.dll", + "**/*.dylib", + "**/*.exe", + "**/*.gcode", + "**/*.gpg", + "**/*.jks", + "**/*.key", + "**/*.keystore", + "**/*.log", + "**/*.o", + "**/*.obj", + "**/*.p12", + "**/*.pem", + "**/*.pfx", + "**/*.so", + "**/*.step", + "**/*.stl", + "**/*.stp", + "**/*.tfvars", + "**/*.ufp", + "**/.env", + "**/.env-*", + "**/.env.*", + "**/.env_*", + "**/.mypy_cache/**", + "**/.netrc", + "**/.npmrc", + "**/.pytest_cache/**", + "**/.ruff_cache/**", + "**/.venv/**", + "**/__pycache__/**", + "**/build/**", + "**/credentials.json", + "**/customer-data/**", + "**/dist/**", + "**/id_ecdsa", + "**/id_ed25519", + "**/id_rsa", + "**/logdump*/**", + "**/node_modules/**", + "**/out/**", + "**/pii/**", + "**/secrets/**", + "**/service-account*.json", + "**/target/**", + "**/third_party/**", + "**/vendor/**", + "**/venv/**" + ] +} diff --git a/.agents/file-size-baseline.json b/.agents/file-size-baseline.json new file mode 100644 index 0000000..9b0d359 --- /dev/null +++ b/.agents/file-size-baseline.json @@ -0,0 +1,8 @@ +{ + "budget": 400, + "comment": "Files recorded here were already over budget when the ratchet was introduced. They may shrink, never grow. Entries are removed automatically once a file falls within budget. Do not add entries by hand to excuse a new violation.", + "exclude": [], + "exclude_comment": "Path fragments excluded from the budget entirely \u2014 use for vendored or generated trees this project does not own. After editing, re-run with --init to reseed.", + "files": {}, + "policy": "ratchet" +} diff --git a/.agents/hooks.json b/.agents/hooks.json new file mode 100644 index 0000000..a8f811a --- /dev/null +++ b/.agents/hooks.json @@ -0,0 +1,52 @@ +{ + "safety-and-compliance": { + "PreToolUse": [ + { + "matcher": "run_command|write_to_file|replace_file_content|multi_replace_file_content", + "hooks": [ + { + "type": "command", + "command": "python3 .agents/hooks/pretool_guard.py", + "timeout": 15 + } + ] + }, + + { + "matcher": "run_command", + "hooks": [ + { + "type": "command", + "command": "python3 .agents/hooks/block_name_matched_kill.py", + "timeout": 10 + } + ] + }, + + { + "matcher": "run_command|write_to_file|replace_file_content|multi_replace_file_content", + "hooks": [ + { + "type": "command", + "command": "python3 .agents/hooks/suggest-skills.py --pre-edit", + "timeout": 15 + } + ] + } + + ], + "PostToolUse": [ + { + "matcher": "write_to_file|replace_file_content|multi_replace_file_content", + "hooks": [ + { + "type": "command", + "command": "bash .agents/hooks/post-edit-linter.sh", + "timeout": 60 + } + ] + } + + ] + } +} diff --git a/.agents/hooks/audit_quad_agent_parity.py b/.agents/hooks/audit_quad_agent_parity.py new file mode 100755 index 0000000..dc8dc1f --- /dev/null +++ b/.agents/hooks/audit_quad_agent_parity.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +""" +audit_quad_agent_parity.py + +Asserts that every hook a repository qualifies for is present in every platform +rendering that supports hooks. + +The required set is derived from the generated configs themselves rather than +hardcoded. A hardcoded list is how the previous version came to require six +hooks while the bootstrap installed eleven — and the five it never checked were +the five added most recently. + +Platform support, verified rather than assumed: + * Antigravity .agents/hooks.json + * Claude Code .claude/settings.json + * Copilot .github/hooks/copilot-hooks.json + * OpenCode has no command-hook mechanism, so it is checked for + configuration presence only; its enforcement floor is + pre-commit. +""" + +import json +import re +import sys +from pathlib import Path + +# Installed beside this script in .agents/hooks/, and beside it in the skill's +# own scripts/ directory, so the same import works in both places. +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from copilot_index import check as check_copilot_index # noqa: E402 + +CONFLICT_MARKER = re.compile(r"^(<{7} |={7}$|>{7} )", re.MULTILINE) + + +def load_json(path: Path): + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print(f"[X] {path}: cannot be read ({exc})") + return None + + +def script_name(command: str) -> str: + """Reduce a platform-specific command line to the hook script's basename. + + Only managed hooks — commands that route through .agents/hooks/ — take part + in the parity contract. A platform may additionally wire repository-local + hooks living elsewhere (scripts/hooks/, an inline shell guard); those speak + that platform's dialect by construction and demanding they exist on every + other platform would force either a false failure or a broken port. The + old last-token heuristic also choked on compound shell commands, reporting + '}' as a missing hook. + """ + if not command: + return "" + for token in command.split(): + # Explicitly routed through the managed hooks directory (Claude, + # Copilot), or a bare script name resolved against it (Antigravity + # runs hook commands from .agents/hooks/ itself). + if ".agents/hooks/" in token: + return token.rsplit("/", 1)[-1] + if "/" not in token and token.endswith((".py", ".sh")): + return token + return "" + + +def antigravity_hooks(repo: Path): + data = load_json(repo / ".agents" / "hooks.json") + if data is None: + return None + names = set() + for group in data.values(): + if not isinstance(group, dict): + continue + for entries in group.values(): + for entry in entries: + for hook in entry.get("hooks", []): + names.add(script_name(hook.get("command", ""))) + return names - {""} + + +def claude_hooks(repo: Path): + data = load_json(repo / ".claude" / "settings.json") + if data is None: + return None + names = set() + for blocks in (data.get("hooks") or {}).values(): + for block in blocks: + for hook in block.get("hooks", []): + names.add(script_name(hook.get("command", ""))) + return names - {""} + + +def copilot_hooks(repo: Path): + data = load_json(repo / ".github" / "hooks" / "copilot-hooks.json") + if data is None: + return None + names = set() + for entries in (data.get("hooks") or {}).values(): + for hook in entries: + names.add(script_name(hook.get("bash", ""))) + return names - {""} + + +def platform_commands(repo: Path): + """Every literal command each platform will execute.""" + commands = [] + data = load_json(repo / ".agents" / "hooks.json") or {} + for group in data.values(): + if isinstance(group, dict): + for entries in group.values(): + for entry in entries: + commands += [h.get("command", "") + for h in entry.get("hooks", [])] + data = load_json(repo / ".claude" / "settings.json") or {} + for blocks in (data.get("hooks") or {}).values(): + for block in blocks: + commands += [h.get("command", "") for h in block.get("hooks", [])] + data = load_json(repo / ".github" / "hooks" / "copilot-hooks.json") or {} + for entries in (data.get("hooks") or {}).values(): + commands += [h.get("bash", "") for h in entries] + return [c for c in commands if c] + + +def audit_command_paths(repo: Path) -> bool: + """Literal path validity. Reducing to basenames is exactly how a doubled + `.agents/hooks/.agents/hooks/` path passed its own audit.""" + ok = True + for command in platform_commands(repo): + for token in command.split(): + if ".agents/hooks/" not in token: + continue + if token.count(".agents/hooks/") != 1: + print(f"[X] doubled hook path in command: {command}") + ok = False + elif not (repo / token.removeprefix("./")).exists(): + print(f"[X] command references a missing script: {command}") + ok = False + return ok + + +def audit_rule_mirrors(repo: Path) -> bool: + """Structural rule-set diff across platform dirs, not a count.""" + ok = True + canonical = {p.name for p in (repo / ".agents" / "rules").glob("*.md") + if not p.is_symlink()} if (repo / ".agents" / "rules").is_dir() else set() + seen_prefix = {} + for name in sorted(canonical): + m = re.match(r"^(\d{2})-", name) + if m: + if m.group(1) in seen_prefix: + print(f"[X] duplicate rule prefix {m.group(1)}: {name} and " + f"{seen_prefix[m.group(1)]}") + ok = False + seen_prefix[m.group(1)] = name + for mirror in (repo / ".claude" / "rules", repo / ".opencode" / "rules"): + if not mirror.is_dir(): + continue + for entry in sorted(mirror.glob("*.md")): + if entry.name == "agents.md": + continue + if entry.is_symlink() and not entry.exists(): + print(f"[X] broken symlink: {entry}") + ok = False + elif entry.name not in canonical: + print(f"[X] platform-only stray rule: {entry} " + "(not in .agents/rules/)") + ok = False + missing = canonical - {e.name for e in mirror.glob("*.md")} + if missing: + print(f"[X] {mirror}: missing mirrors for {sorted(missing)}") + ok = False + return ok + + +def audit_copilot_rule_index(repo: Path) -> bool: + """BOTH directions of the Copilot rule list. + + Copilot has no rule-directory convention: `.github/copilot-instructions.md` + is the only place it learns which rules exist. The previous check ran one + way only — every listed rule must exist — so the two failures that actually + happened in the field went unreported and the audit printed PASSED: + + * a rule present in `.agents/rules/` but absent from the index (a + repo-owned or custom-band rule, or any rule added after the bootstrap + run that rendered the list), and + * an index never refreshed after the rule set changed. + + Missing-from-the-index is the direction that loses enforcement, so it is a + failure, not a warning. + """ + problems = check_copilot_index(repo) + for problem in problems: + print(f"[X] {problem}") + if problems: + print(" The Copilot rule index is stale. Refresh it with " + "`python3 .agents/hooks/copilot_index.py` (or " + "`bash scripts/sync_agentic_configs.sh`).") + return False + print("[ok] Copilot: rule index matches .agents/rules/ in both directions") + return True + + +def _check_duplicate_top_keys(path: Path) -> bool: + """PyYAML silently last-wins on duplicate top-level keys — the mechanism + that dropped 8+ hooks from half-merged configs without a parse error.""" + seen, ok = set(), True + for line in path.read_text(errors="ignore").splitlines(): + m = re.match(r"^([A-Za-z_][\w-]*):", line) + if m: + if m.group(1) in seen: + print(f"[X] {path.name}: duplicate top-level key " + f"'{m.group(1)}' — YAML silently drops one side") + ok = False + seen.add(m.group(1)) + return ok + + +def audit_structured_files(repo: Path) -> bool: + ok = True + precommit = repo / ".pre-commit-config.yaml" + if precommit.exists(): + ok &= _check_duplicate_top_keys(precommit) + try: + import yaml # type: ignore + yaml.safe_load(precommit.read_text(errors="ignore")) + except ImportError: + pass + except Exception as exc: # noqa: BLE001 + print(f"[X] .pre-commit-config.yaml does not parse: {exc}") + ok = False + for rel in (".agents/hooks.json", ".claude/settings.json", + ".github/hooks/copilot-hooks.json", "opencode.json", + ".agents/rules-manifest.json", ".agents/bootstrap-profile.json"): + path = repo / rel + if path.exists() and load_json(path) is None: + ok = False + for rel in (".pre-commit-config.yaml", ".talismanrc", "opencode.json", + ".agents/hooks.json", ".claude/settings.json"): + path = repo / rel + if path.exists() and CONFLICT_MARKER.search(path.read_text(errors="ignore")): + print(f"[X] {rel}: contains conflict markers") + ok = False + return ok + + +# Parity is the default and a divergence is a defect — EXCEPT where a harness +# already provides the capability natively. Recording the exception here (rather +# than relaxing the check) keeps an accidental omission a failure. +HARNESS_EXEMPT = { + # Claude reads the `paths:` front matter in .claude/rules and loads the + # matching skill itself; a hook there duplicates the harness on every edit. + "suggest-skills.py": ("Claude Code",), +} + + +def audit_parity(repo_path: Path) -> bool: + repo = repo_path.resolve() + print(f"==> Auditing agent-platform parity in: {repo.name}") + passed = True + + platforms = { + "Antigravity": antigravity_hooks(repo), + "Claude Code": claude_hooks(repo), + "Copilot": copilot_hooks(repo), + } + + available = [hooks for hooks in platforms.values() if hooks] + if not available: + print("[X] No platform hook configuration found — run the bootstrap first.") + return False + + # The union is what this repository qualifies for; every platform must carry + # all of it. This catches a hook added to one config and forgotten in another. + expected: set[str] = available[0].union(*available[1:]) + for name, hooks in platforms.items(): + if hooks is None: + print(f"[X] {name}: configuration missing or unreadable") + passed = False + continue + missing = {script for script in expected - hooks + if name not in HARNESS_EXEMPT.get(script, ())} + deliberate = sorted((expected - hooks) - missing) + if missing: + print(f"[X] {name}: missing {sorted(missing)}") + passed = False + else: + note = (f" (deliberately not wired: {', '.join(deliberate)} — " + "the harness provides this natively)" if deliberate else "") + print(f"[ok] {name}: {len(hooks)} hook(s){note}") + + # Every referenced script must exist, or the config entry is a silent no-op. + hooks_dir = repo / ".agents" / "hooks" + for script in sorted(expected): + if not (hooks_dir / script).exists(): + print(f"[X] {script} is referenced by a platform config " + "but is not installed") + passed = False + + # ...and the reverse: a hook installed but called by nothing is dead code + # that reads as enforcement. This is how check_upstream_alignment.py sat in + # .agents/hooks/ enforcing nothing while a PR opened 21 commits behind its + # base. Scan every place a hook can legitimately be invoked from. + config_callers = "" + for caller in (repo / ".pre-commit-config.yaml", + repo / "scripts" / "verify_and_create_pr.sh", + repo / "scripts" / "sync_agentic_configs.sh", + repo / ".claude" / "settings.json", + repo / ".agents" / "hooks.json", + repo / ".github" / "hooks" / "copilot-hooks.json", + repo / "opencode.json"): + if caller.exists(): + config_callers += caller.read_text(errors="ignore") + + # A hook invoked by a sibling hook is wired too (post-edit-linter.sh calls + # suggest-skills.py), so sibling sources count as callers — but a script + # must not vouch for itself, hence the per-script exclusion below. + hook_sources = {} + if hooks_dir.is_dir(): + for path in sorted(hooks_dir.iterdir()): + if path.is_file() and path.suffix in (".py", ".sh"): + hook_sources[path.name] = path.read_text(errors="ignore") + + # The auditor is an entry point, run by hand and by the PR gate's + # instructions rather than referenced from a config. + ENTRY_POINTS = {"audit_quad_agent_parity.py"} + + for script in sorted(hook_sources): + if script in ENTRY_POINTS: + continue + siblings = "".join(src for name, src in hook_sources.items() + if name != script) + stem = script.rsplit(".", 1)[0] + if script not in config_callers + siblings and stem not in config_callers + siblings: + print(f"[X] {script} is installed but referenced by no " + "config or script — it enforces nothing") + passed = False + + if not (repo / "opencode.json").exists(): + print("[X] OpenCode: opencode.json missing") + passed = False + else: + print("[ok] OpenCode: configured (no command-hook mechanism; " + "its enforcement floor is pre-commit)") + + for required in (".pre-commit-config.yaml", ".aiignore", "AGENTS.md"): + if not (repo / required).exists(): + print(f"[X] {required} is missing") + passed = False + + passed &= audit_command_paths(repo) + passed &= audit_rule_mirrors(repo) + passed &= audit_copilot_rule_index(repo) + passed &= audit_structured_files(repo) + + print("==> Parity audit " + ("PASSED" if passed else "FAILED")) + return bool(passed) + + +def main(): + target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".") + if not audit_parity(target): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.agents/hooks/block-absolute-paths.py b/.agents/hooks/block-absolute-paths.py new file mode 100755 index 0000000..6a96fb6 --- /dev/null +++ b/.agents/hooks/block-absolute-paths.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +import os +import subprocess +import sys + +HOOKS_DIR = os.path.abspath(os.path.dirname(__file__)) +if HOOKS_DIR not in sys.path: + sys.path.insert(0, HOOKS_DIR) +from path_scanner import PathScanner # noqa: E402 + +# Hook may be invoked from .agents/ (Antigravity sets cwd to the hooks.json directory) +_ROOT = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True +).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + + +def main(): + if PathScanner.scan_staged(): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.agents/hooks/block-secrets.py b/.agents/hooks/block-secrets.py new file mode 100755 index 0000000..bf1219a --- /dev/null +++ b/.agents/hooks/block-secrets.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +import os +import subprocess +import sys + +HOOKS_DIR = os.path.abspath(os.path.dirname(__file__)) +if HOOKS_DIR not in sys.path: + sys.path.insert(0, HOOKS_DIR) +from secret_scanner import SecretScanner # noqa: E402 + +# Hook may be invoked from .agents/ (Antigravity sets cwd to the hooks.json directory) +_ROOT = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True +).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + + +def main(): + if SecretScanner.scan_staged(): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.agents/hooks/block_name_matched_kill.py b/.agents/hooks/block_name_matched_kill.py new file mode 100755 index 0000000..aa7d003 --- /dev/null +++ b/.agents/hooks/block_name_matched_kill.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" +block_name_matched_kill.py + +Refuses to kill a process chosen by name pattern. +""" + +import json +import re +import shlex +import sys + +BY_NAME = ("pkill", "killall") +SUBSTITUTION = re.compile(r"\$\(|`|\$\{") + +REASON = """Refused: this kills processes selected by name, not by PID. + +Worktrees on this machine share a repository, a Godot binary and a build cache, +so a name match reaches other sessions' processes as readily as your own. + +Do this instead: + + pgrep -af # find candidates (pgrep -fl on macOS) + ls -l /proc//cwd # confirm it is YOUR worktree (Linux) + lsof -a -p -d cwd # same, on macOS + kill # signal that one, by number + +If the process is not in your worktree, it is not yours to signal.""" + + +def offending_command(command: str) -> str | None: + """Return the reason this command is refused, or None if it is fine.""" + try: + tokens = shlex.split(command) + except ValueError: + tokens = command.split() + + for index, token in enumerate(tokens): + name = token.rsplit("/", 1)[-1] + if name in BY_NAME: + return f"`{name}` selects processes by name" + if name == "kill": + rest = tokens[index + 1:] + targets = [t for t in rest if not t.startswith("-")] + if not targets: + continue + if any(SUBSTITUTION.search(t) for t in targets): + return "`kill` is being given a command substitution, not a PID" + if not all(t.isdigit() for t in targets): + return "`kill` is being given something that is not a literal PID" + return None + + +# This hook is wired into all four harnesses, and they neither deliver the tool +# call nor read a decision in the same shape. Reading only Claude's +# `tool_input.command` and always printing Antigravity's `{"decision": "deny"}` +# meant that under Copilot and Antigravity the hook could not even see the +# command: it FAILED OPEN and blocked nothing at all, while appearing wired. +# +# Extraction and emission are therefore both per-harness, and the harness is +# inferred from the payload rather than configured, so one script stays correct +# in every configuration that references it. + +def extract_command(payload: dict) -> str: + """The shell command from any supported harness's tool-call payload.""" + # Claude Code: {"tool_name": "Bash", "tool_input": {"command": ...}} + command = (payload.get("tool_input") or {}).get("command") + if command: + return command + # GitHub Copilot: {"toolName": ..., "toolArgs": {"command": ...}} + args = payload.get("toolArgs") or payload.get("tool_args") or {} + if isinstance(args, dict) and args.get("command"): + return args["command"] + # Antigravity: {"toolCall": {"args": {"command"|"CommandLine": ...}}} + call_args = ((payload.get("toolCall") or payload.get("tool_call") or {}) + .get("args") or {}) + if isinstance(call_args, dict): + for key in ("command", "CommandLine", "commandLine"): + if call_args.get(key): + return call_args[key] + return "" + + +def detect_harness(payload: dict) -> str: + if payload.get("tool_input") is not None or payload.get("tool_name"): + return "claude" + if payload.get("toolArgs") is not None or payload.get("toolName"): + return "copilot" + if payload.get("toolCall") is not None or payload.get("tool_call"): + return "antigravity" + return "claude" + + +def deny_payload(harness: str, reason: str) -> dict: + """The refusal in the schema THIS harness actually reads.""" + if harness == "claude": + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + } + if harness == "copilot": + return {"permissionDecision": "deny", "permissionDecisionReason": reason} + return {"decision": "deny", "reason": reason} + + +def main() -> int: + try: + payload = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + return 0 # never block because the hook could not parse its own input + + command = extract_command(payload) + if not command: + return 0 + + # A whole-command substitution can hide the verb; check the raw text too. + reason = offending_command(command) + if reason is None and SUBSTITUTION.search(command): + for name in BY_NAME: + if re.search(rf"\b{name}\b", command): + reason = f"`{name}` selects processes by name" + break + + if reason is None: + return 0 + + harness = detect_harness(payload) + print(json.dumps(deny_payload(harness, f"{reason}.\n\n{REASON}"))) + # Exit 2 as well: Claude Code treats a non-zero PreToolUse exit as a block + # even when it does not parse the JSON, so the refusal cannot be lost. + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/hooks/check_atomic_bisect_history.py b/.agents/hooks/check_atomic_bisect_history.py new file mode 100755 index 0000000..bedb1e8 --- /dev/null +++ b/.agents/hooks/check_atomic_bisect_history.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +""" +check_atomic_bisect_history.py + +`git bisect` is only useful if every commit on the branch BUILDS. A tidy commit +title does not give you that; a build does. So this hook checks both: + + 1. History hygiene — no `fixup!`/`squash!`/WIP titles left before a push. + 2. Buildability — the repository's OWN build (and, if quick, test) command is + run against the committed tree, so a branch that cannot compile is caught + here rather than in the middle of a bisect three months from now. + +The build commands below were detected from this repository at bootstrap time. +Set `SKIP_BISECT_BUILD=1` to skip step 2 when you know the build is expensive. +""" + +import os +import subprocess +import sys + +# Hook may be invoked from .agents/ (Antigravity sets cwd to the hooks.json +# directory) — always operate from the repository root. +_ROOT = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True +).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + +# --- this repository's layout, discovered at bootstrap (generated) --------- +# ONE source for the folder lists. Several hooks used to carry their own +# hardcoded copies of a vendor-directory list and of a default-branch list, +# which was both duplication and wrong: a firmware repository vendors into its +# own SDK directory and protects a release branch under a project-specific +# name, and no hardcoded copy could know either. +# +# Every value below comes from the investigation the bootstrap ran against THIS +# repository — not from a default list. Re-run the bootstrap with `--update` +# after the layout changes. + +#: Trees this repository consumes but does not own. Never reformat or edit. +VENDORED_PREFIXES: tuple[str, ...] = () + +#: Branches nobody may commit to directly. Discovered from the remote's own +#: protection settings via `gh`, falling back to the detected base branch. +PROTECTED_BRANCHES: tuple[str, ...] = ( + 'master', +) + +#: The PR base for this repository, recorded once so no script has to guess. +BASE_BRANCH: str = "master" + +#: Directories holding a published interface whose docs must move with it. +INTERFACE_PREFIXES: tuple[str, ...] = () + +#: Where this repository documents that interface. +API_DOC_PATHS: tuple[str, ...] = () + +#: Sources where a raw #RRGGBB literal belongs in a theme token instead. +#: Not QML-only: React, Python UIs and stylesheets hardcode colours too. +THEMEABLE_SUFFIXES: tuple[str, ...] = ( + '.qml', + '.py', + '.css', + '.scss', + '.less', +) + +#: The theme/token definitions themselves — the one place literals belong. +THEME_DEFINITION_FILES: tuple[str, ...] = ( + 'Theme.qml', + 'theme.ts', + 'tokens.css', +) + + +def is_vendored(path: str) -> bool: + return any(path.startswith(prefix) for prefix in VENDORED_PREFIXES) + + +def is_themeable_source(path: str) -> bool: + return (path.endswith(THEMEABLE_SUFFIXES) + and not any(name in path for name in THEME_DEFINITION_FILES)) + + +#: This repository's build entry points, detected at bootstrap. +BUILD_COMMANDS: tuple[str, ...] = () +#: Its test runner, if the investigation found one. +TEST_COMMANDS: tuple[str, ...] = ( + 'pytest -x -q cfg', +) + +BUILD_TIMEOUT_SECONDS = 900 + + +def get_base_branch() -> str: + """The PR base, recorded once at bootstrap. Candidate-list guessing was + blind to any base whose name is not one of three defaults.""" + for cand in (BASE_BRANCH,) + PROTECTED_BRANCHES: + try: + subprocess.check_output( + ["git", "rev-parse", "--verify", f"origin/{cand}"], + stderr=subprocess.DEVNULL, + ) + return f"origin/{cand}" + except (subprocess.CalledProcessError, OSError): + continue + return "HEAD~1" + + +def check_titles(base_branch: str) -> None: + res = subprocess.run( + ["git", "log", f"{base_branch}..HEAD", "--oneline"], + capture_output=True, + text=True, + ) + if res.returncode != 0: + return + + commits = [line.strip() for line in res.stdout.splitlines() if line.strip()] + fixup_keywords = ["fixup!", "squash!", "wip", "work in progress", "temp"] + dirty = [c for c in commits if any(kw in c.lower() for kw in fixup_keywords)] + if dirty: + print("HISTORY WARNING: Temporary/WIP commits detected before push:") + for commit in dirty: + print(f" - {commit}") + print("Consider squashing/cleaning history (`git rebase -i`) before review.") + + +def working_tree_is_clean() -> bool: + """Only a committed tree proves anything about a bisect. Building with + uncommitted work in place would verify code that no commit contains.""" + res = subprocess.run(["git", "status", "--porcelain"], + capture_output=True, text=True) + return res.returncode == 0 and not res.stdout.strip() + + +def run_build() -> int: + if os.environ.get("SKIP_BISECT_BUILD"): + print("BISECT BUILD: skipped (SKIP_BISECT_BUILD is set).") + return 0 + if not BUILD_COMMANDS and not TEST_COMMANDS: + print("BISECT BUILD: no build or test command was detected for this " + "repository, so buildability cannot be verified here. Re-run the " + "bootstrap with --update once one exists.") + return 0 + if not working_tree_is_clean(): + print("BISECT BUILD: skipped — the working tree has uncommitted " + "changes, so a build would not describe any commit. Commit first.") + return 0 + + for command in BUILD_COMMANDS + TEST_COMMANDS: + print(f"BISECT BUILD: verifying the committed tree with `{command}` ...") + try: + res = subprocess.run(command, shell=True, + timeout=BUILD_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + print(f"BISECT BUILD WARNING: `{command}` exceeded " + f"{BUILD_TIMEOUT_SECONDS}s and was abandoned; buildability " + "is unverified for this push.") + return 0 + except OSError as exc: + print(f"BISECT BUILD WARNING: could not run `{command}`: {exc}") + return 0 + if res.returncode != 0: + print("\nBISECT SAFETY FAILURE: the committed tip of this branch " + f"does not build (`{command}` exited {res.returncode}).\n" + " Every commit you push must build on its own, or `git " + "bisect` will land on it and report a false culprit.\n" + " Fix the build and amend, or export SKIP_BISECT_BUILD=1 if " + "you are knowingly pushing a broken tip.") + return 1 + print("BISECT BUILD: the committed tree builds.") + return 0 + + +def main() -> int: + check_titles(get_base_branch()) + return run_build() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/hooks/check_file_size_budget.py b/.agents/hooks/check_file_size_budget.py new file mode 100755 index 0000000..0c30c6e --- /dev/null +++ b/.agents/hooks/check_file_size_budget.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +""" +check_file_size_budget.py + +File-size ratchet for an existing codebase. + +Two tiers: + * Files at or under the budget must stay at or under it. + * Files already over the budget when the ratchet was introduced are + grandfathered at their recorded size: they may shrink, never grow. When one + shrinks, its ceiling is tightened so the space cannot be reclaimed later. + +The baseline lives in .agents/file-size-baseline.json and is part of the repository. +New files are never grandfathered — a file created after the baseline must meet +the budget outright. + +Modes: + --init seed the baseline from the current tree (run once) + --staged check staged files, tighten ceilings that improved (pre-commit) + --changed check files changed against HEAD, report only (agent hooks) + --report [paths] planning aid: show remaining headroom for the given paths +""" + +import os +import subprocess +import sys +from pathlib import Path + +_HOOKS_DIR = os.path.abspath(os.path.dirname(__file__)) +if _HOOKS_DIR not in sys.path: + sys.path.insert(0, _HOOKS_DIR) +# The ratchet is three modules, split because they change for different +# reasons. Baseline persistence, measurement and merge reconciliation live in +# file_size_baseline; the decision about which files are in scope at all, and +# WHY any one of them is exempt, lives in file_size_scope. This file stays what +# it says it is: the command-line surface. +from file_size_baseline import ( # noqa: E402 + BASELINE_PATH, + BUDGET, + commit_baseline, + count_lines, + git_files, + load_baseline, + raise_ceilings_for_merge, +) +from file_size_scope import ( # noqa: E402 + exemption_for, + is_checked, +) + +ESTABLISHED_PATTERNS: list[str] = [] + +_ROOT = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True +).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + + +def decomposition_guidance(path: str) -> str: + pattern_hint = "" + if ESTABLISHED_PATTERNS: + names = ", ".join("*" + p for p in ESTABLISHED_PATTERNS) + pattern_hint = ( + "\n 5. Follow this repository's own vocabulary — it already uses " + + names + ".\n Put the extracted unit where those live" + " and register it the same way." + ) + return ( + "\n HOW TO FIX THIS (read before editing):\n" + " Do NOT satisfy this check by deleting blank lines, collapsing formatting,\n" + " inlining variables, shortening names, or moving code into comments. Those\n" + " reduce the line count without reducing complexity, and will be rejected in\n" + " review. Reduce the file by moving responsibility out of it.\n\n" + " Work through these in order:\n" + " 1. Single Responsibility — list the distinct reasons this file has to\n" + " change. Each separate reason is a separate module.\n" + " 2. Open/Closed — find the conditional or switch that grows\n" + " every time a\n" + " case is added, and replace it with polymorphism (Strategy) or a\n" + " registry/Factory, so new cases are added without editing this file.\n" + " 3. DRY — extract logic repeated here or duplicated elsewhere.\n" + " 4. Dependency direction — separate I/O, parsing, and\n" + " configuration from\n" + " the core logic so each part is independently testable." + + pattern_hint + + "\n\n If the extraction genuinely does not belong to the current\n" + " ticket, state\n" + " that explicitly in the pull request description. Do not bypass this check\n" + " silently." + ) + + +def check(paths, baseline, tighten: bool): + grandfathered = baseline.get("files", {}) + budget = baseline.get("budget", BUDGET) + extra = tuple(baseline.get("exclude", [])) + failures, tightened = [], {} + + for path in paths: + # Under --staged the file may legitimately be absent from the working + # tree (staged deletion is handled by the prune below). + if not Path(path).is_file() or not is_checked(path, extra, tighten): + continue + lines = count_lines(path, tighten) + if lines is None: + continue + ceiling = grandfathered.get(path) + + if ceiling is None: + if lines > budget: + failures.append( + "FILE SIZE BUDGET EXCEEDED: {}\n" + " current: {} lines | budget: {} lines | over by: {}\n" + " This file is not grandfathered, so it must meet " + "the budget.".format( + path, lines, budget, lines - budget) + + decomposition_guidance(path)) + elif lines > ceiling: + failures.append( + "GRANDFATHERED FILE GREW: {}\n" + " current: {} lines | ceiling: {} lines | budget: {} lines\n" + " This file was already over budget when the ratchet was introduced,\n" + " so it may shrink but never grow. This change adds {} line(s).\n" + " Put the new code in a new, properly sized module, or\n" + " bring this file\n" + " below its ceiling as part of the same change.".format( + path, lines, ceiling, budget, lines - ceiling) + + decomposition_guidance(path)) + elif lines < ceiling: + tightened[path] = lines + + # Only tighten when the run is otherwise clean. Recording a tighter ceiling + # for a commit that is about to be rejected would strand the baseline ahead + # of the tree: if the author then reverts the shrink, the file would be + # measured against a ceiling it never actually reached. + if failures: + tighten = False + + # Drop entries for files that no longer exist. Leaving them behind is a + # bypass, not a courtesy: deleting a grandfathered path and recreating it + # would otherwise let a brand-new oversized file inherit the old ceiling. + removed = [p for p in grandfathered if not Path(p).is_file()] + if removed and tighten: + for path in removed: + grandfathered.pop(path, None) + commit_baseline(baseline, grandfathered) + print("File-size ratchet: dropped {} deleted file(s) from the baseline:" + .format(len(removed))) + for path in sorted(removed): + print(" {}".format(path)) + + if tightened and tighten: + for path, lines in tightened.items(): + if lines <= budget: + grandfathered.pop(path, None) + else: + grandfathered[path] = lines + commit_baseline(baseline, grandfathered) + print("File-size ratchet tightened (this improvement is now the new ceiling):") + for path, lines in sorted(tightened.items()): + print(" {} -> {} lines{}".format( + path, lines, " (now within budget, no longer grandfathered)" + if lines <= budget else "")) + elif tightened and not failures: + print("File-size ratchet: {} file(s) shrank. Run with --staged to record " + "the tighter ceilings.".format(len(tightened))) + + return failures + + +def do_init(): + existing = load_baseline() + extra = tuple(existing.get("exclude", [])) + tracked = git_files("ls-files") + files = {} + for path in tracked: + if not Path(path).is_file() or not is_checked(path, extra): + continue + lines = count_lines(path) + if lines is not None and lines > BUDGET: + files[path] = lines + baseline = { + "budget": BUDGET, + "policy": "ratchet", + "comment": ("Files recorded here were already over budget when the ratchet " + "was introduced. They may shrink, never grow. Entries are removed " + "automatically once a file falls within budget. Do not add entries " + "by hand to excuse a new violation."), + "exclude": list(extra), + "exclude_comment": ("Path fragments excluded from the budget entirely — use " + "for vendored or generated trees this project does not " + "own. After editing, re-run with --init to reseed."), + "files": files, + } + commit_baseline(baseline, files) + print("Seeded {} with {} grandfathered file(s) over {} lines.".format( + BASELINE_PATH, len(files), BUDGET)) + if files: + worst = sorted(files.items(), key=lambda kv: -kv[1])[:5] + print("Largest:") + for path, lines in worst: + print(" {} lines {}".format(lines, path)) + if len(files) > 50: + tops = {} + for path in files: + top = path.split("/")[0] if "/" in path else "." + tops[top] = tops.get(top, 0) + 1 + ranked = sorted(tops.items(), key=lambda kv: -kv[1])[:5] + print("") + print("WARNING: {} files were grandfathered. That is high enough to suggest " + "vendored or".format(len(files))) + print("generated code is being counted as this project's own. Contributors by " + "top-level directory:") + for top, count in ranked: + print(" {}: {} file(s)".format(top, count)) + print("Review the entries; add path fragments to the \"exclude\" list in " + "{} and re-run".format(BASELINE_PATH)) + print("with --init to reseed. A baseline full of third-party code makes the " + "ratchet meaningless.") + + +def _report_whole_tree(extra): + """Every tracked file the budget covers, plus the ones it silently did not. + + Listing every exempt path here would bury the report under the whole tree's + documentation and assets, so only the CONTENT-addressed exemptions are named + individually: those are the ones invisible from a listing, and the ones that + were found excusing a first-party file. Path-shaped exemptions are + self-evident from the path, so they are counted rather than enumerated. + """ + checked, exempt = [], [] + for path in git_files("ls-files"): + verdict = exemption_for(path, extra) + if verdict is None: + checked.append(path) + elif verdict.content_addressed: + exempt.append((path, verdict)) + if exempt: + print("NOT MEASURED — {} file(s) exempted by their content, not their " + "path:".format(len(exempt))) + for path, verdict in sorted(exempt): + print(" {}: {}".format(path, verdict)) + print("") + return checked + + +def do_report(paths): + baseline = load_baseline() + grandfathered = baseline.get("files", {}) + budget = baseline.get("budget", BUDGET) + extra = tuple(baseline.get("exclude", [])) + if not paths: + paths = _report_whole_tree(extra) + print("File-size headroom (budget {} lines):".format(budget)) + for path in paths: + # Say why a file is out of scope rather than skipping it in silence. + # The old report answered "unchecked suffix" for a file whose suffix + # was fine, which is how a content-addressed exemption stayed hidden. + exempt = exemption_for(path, extra) + if exempt is not None: + print(" {}: NOT MEASURED — {}".format(path, exempt)) + continue + if not Path(path).is_file(): + print(" {}: new file — must be written within {} lines" + .format(path, budget)) + continue + lines = count_lines(path) + if lines is None: + continue + ceiling = grandfathered.get(path, budget) + state = "grandfathered" if path in grandfathered else "budget" + headroom = ceiling - lines + verdict = ("{} line(s) of headroom".format(headroom) if headroom > 0 + else "AT CEILING — any addition must be offset by an extraction") + print(" {}: {} lines / {} {} — {}".format( + path, lines, ceiling, state, verdict)) + print("\nPlan extractions before writing code: if the work does not fit the " + "headroom above,\ndecide now which responsibility moves out and where it " + "goes.") + + +def main(): + args = sys.argv[1:] + if "--init" in args: + do_init() + return + if "--report" in args: + do_report([a for a in args if not a.startswith("--")]) + return + + baseline = load_baseline() + raise_ceilings_for_merge(baseline) + if "--changed" in args: + paths = git_files("diff", "--name-only", "HEAD") + tighten = False + else: + paths = git_files("diff", "--cached", "--name-only") + tighten = True + explicit = [a for a in args if not a.startswith("--")] + if explicit: + paths, tighten = explicit, False + + failures = check(paths, baseline, tighten) + if failures: + print("=" * 78) + for failure in failures: + print(failure) + print("-" * 78) + print("=" * 78) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.agents/hooks/check_multi_intent_scope.py b/.agents/hooks/check_multi_intent_scope.py new file mode 100755 index 0000000..7180115 --- /dev/null +++ b/.agents/hooks/check_multi_intent_scope.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +""" +check_multi_intent_scope.py — the repository's single scope gate. + +This absorbs the old `check-relevant-scope.py`. The two hooks asked the same +question ("is this change one thing?") and answered it twice: one counted files +against an arbitrary threshold, the other clustered directories. A count is not +evidence of scope creep — a rename touches sixty files with one intent, and two +files in unrelated subsystems are two intents. So this hook does exactly two +things: + + * BLOCKS the one objective violation — staged edits to trees this repository + vendors but does not own. + * REPORTS the changed-file list, grouped by where those files live, and hands + the judgement to the agent. No threshold, no guessing. +""" + +import os +import re +import subprocess +import sys + +# Hook may be invoked from .agents/ (Antigravity sets cwd to the hooks.json +# directory) — always operate from the repository root. +_ROOT = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True +).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + +# --- this repository's layout, discovered at bootstrap (generated) --------- +# ONE source for the folder lists. Several hooks used to carry their own +# hardcoded copies of a vendor-directory list and of a default-branch list, +# which was both duplication and wrong: a firmware repository vendors into its +# own SDK directory and protects a release branch under a project-specific +# name, and no hardcoded copy could know either. +# +# Every value below comes from the investigation the bootstrap ran against THIS +# repository — not from a default list. Re-run the bootstrap with `--update` +# after the layout changes. + +#: Trees this repository consumes but does not own. Never reformat or edit. +VENDORED_PREFIXES: tuple[str, ...] = () + +#: Branches nobody may commit to directly. Discovered from the remote's own +#: protection settings via `gh`, falling back to the detected base branch. +PROTECTED_BRANCHES: tuple[str, ...] = ( + 'master', +) + +#: The PR base for this repository, recorded once so no script has to guess. +BASE_BRANCH: str = "master" + +#: Directories holding a published interface whose docs must move with it. +INTERFACE_PREFIXES: tuple[str, ...] = () + +#: Where this repository documents that interface. +API_DOC_PATHS: tuple[str, ...] = () + +#: Sources where a raw #RRGGBB literal belongs in a theme token instead. +#: Not QML-only: React, Python UIs and stylesheets hardcode colours too. +THEMEABLE_SUFFIXES: tuple[str, ...] = ( + '.qml', + '.py', + '.css', + '.scss', + '.less', +) + +#: The theme/token definitions themselves — the one place literals belong. +THEME_DEFINITION_FILES: tuple[str, ...] = ( + 'Theme.qml', + 'theme.ts', + 'tokens.css', +) + + +def is_vendored(path: str) -> bool: + return any(path.startswith(prefix) for prefix in VENDORED_PREFIXES) + + +def is_themeable_source(path: str) -> bool: + return (path.endswith(THEMEABLE_SUFFIXES) + and not any(name in path for name in THEME_DEFINITION_FILES)) + + + +def _git_lines(*args) -> list: + res = subprocess.run(["git", *args], capture_output=True, text=True) + if res.returncode != 0: + return [] + return [line.strip() for line in res.stdout.splitlines() if line.strip()] + + +def changed_files() -> list: + """Staged first — that is what a pre-commit run is about to record.""" + for args in (("diff", "--cached", "--name-only"), + ("diff", "--name-only", "HEAD")): + files = _git_lines(*args) + if files: + return files + return [] + + +def block_vendored(files: list) -> list: + return [f for f in files if is_vendored(f)] + + +def group_by_area(files: list) -> dict: + """Two path components deep: deep enough to separate `src/parser` from + `src/transport`, shallow enough not to call every file its own area.""" + areas = {} + for path in files: + parts = path.split("/") + area = "/".join(parts[:2]) if len(parts) > 1 else "(repository root)" + areas.setdefault(area, []).append(path) + return areas + + +def report(files: list) -> None: + areas = group_by_area(files) + print("\n" + "=" * 74) + print("SCOPE REPORT — {} changed file(s) across {} area(s)".format( + len(files), len(areas))) + print("=" * 74) + for area, paths in sorted(areas.items(), key=lambda kv: (-len(kv[1]), kv[0])): + print(" {} ({} file(s))".format(area, len(paths))) + for path in sorted(paths): + print(" {}".format(path)) + + jira_keys = sorted(set(re.findall(r"\b[A-Z]{2,10}-\d+\b", + "\n".join(_git_lines("log", "-n", "5", + "--oneline"))))) + if len(jira_keys) > 1: + print("\n Recent commits reference more than one ticket: {}".format( + ", ".join(jira_keys))) + print(" One pull request should serve one ticket.") + + print("\n JUDGE THIS YOURSELF — the hook deliberately does not decide:") + print(" * Does every file above serve the ONE task this branch is for?") + print(" * Is anything here an opportunistic fix or cleanup you noticed") + print(" along the way ('boy scouting')? That belongs on its own branch:") + print(" bash scripts/create_boyscout_branch_and_pr.sh " + "UC-123 ") + print(" * Files spread over unrelated areas are a signal, not a verdict:") + print(" a rename legitimately touches many; two files in two subsystems") + print(" may still be two intents.") + print("=" * 74 + "\n") + + +def main() -> int: + files = changed_files() + if not files: + return 0 + + vendored = block_vendored(files) + if vendored: + print("SCOPE ERROR: this change edits vendored trees this repository " + "consumes but does not own:") + for path in sorted(vendored): + print(" - {}".format(path)) + print("Vendored code is updated upstream, never patched in place. " + "Unstage these files.") + return 1 + + report(files) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/hooks/check_security_downgrades.py b/.agents/hooks/check_security_downgrades.py new file mode 100755 index 0000000..f1c30e8 --- /dev/null +++ b/.agents/hooks/check_security_downgrades.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +""" +check_security_downgrades.py +---------------------------- +Deterministic pre-commit & pre-PR hook script to detect and block security +feature downgrades in staged diffs. +""" + +import re +import subprocess +import sys + +# Deliberately BROAD. A false positive costs one turn — the author looks at the +# line and says "no, that is a test fixture". A missed downgrade ships an agent's +# workaround for a failing check straight into the product. The asymmetry is not +# close, so these patterns are written to over-match on purpose. +# +# Two families: +# * `_OFF` — a named security control being set to a disabling value. +# * explicit entries — flags and calls whose mere presence is the downgrade. +_OFF = r"\s*[:=]\s*(?:false|0|none|null|off|no|\"\"|'')\b" + +_DISABLED_CONTROLS = [ + # Authentication and authorisation + ("signInRequired", "sign-in requirement disabled"), + ("needs?_authentication", "authentication requirement disabled"), + ("require[_a-z]*auth[a-z_]*", "authentication requirement disabled"), + ("auth[a-z_]*required", "authentication requirement disabled"), + ("authenticat(?:e|ed|ion)", "authentication disabled"), + ("authoriz(?:e|ed|ation)", "authorization disabled"), + ("login_?required", "login requirement disabled"), + ("check_?permissions?", "permission checking disabled"), + ("permission_?check", "permission checking disabled"), + ("enforce[_a-z]*", "an enforcement switch disabled"), + ("scope_?check|check_?scopes?", "scope checking disabled"), + ("csrf[_a-z]*|xsrf[_a-z]*", "CSRF protection disabled"), + # Transport and certificate validation + ("ssl_?verify|verify_?ssl|tls_?verify|verify_?certs?", "TLS verification disabled"), + ("verify", "verification disabled (often TLS certificate validation)"), + ("check_?hostname", "TLS hostname checking disabled"), + ("reject_?unauthorized", "unauthorized TLS peers accepted"), + ("secure|https_?only|http_?only|strict_?transport", + "a transport-security flag disabled"), + ("validate[_a-z]*|validation", "validation disabled"), + ("sanitiz|sanitis|escape[_a-z]*", "input sanitisation disabled"), + # Sandboxing / isolation + ("sandbox(?:ed|ing)?", "sandboxing disabled"), + ("web_?security|same_?origin|cors_?check", "browser origin protection disabled"), + ("read_?only|readonly", "a read-only protection removed"), + ("signature_?check|check_?signature|verify_?signature", + "signature verification disabled"), + ("encrypt(?:ed|ion)?", "encryption disabled"), +] + +SECURITY_DOWNGRADE_PATTERNS = [ + # The trailing suffix allowance matters: `SANDBOX_ENABLED = False` is the + # same downgrade as `sandbox = false`, and anchoring on the bare name missed it. + # The suffix and closing-quote allowances matter: `SANDBOX_ENABLED = False` + # and `config['CSRF_ENABLED'] = 0` are the same downgrade as `sandbox: false`, + # and anchoring on the bare name missed both. + (r"\b(?:" + name + r")[_a-z0-9]*['\"\]]*" + _OFF, description) + for name, description in _DISABLED_CONTROLS +] + [ + # Presence, not assignment: these ARE the downgrade wherever they appear. + (r"NODE_TLS_REJECT_UNAUTHORIZED\s*[:=]\s*['\"]?0", "Node TLS validation disabled"), + (r"PYTHONHTTPSVERIFY\s*[:=]\s*['\"]?0", "Python HTTPS validation disabled"), + (r"curl[^\n]*\s(?:-k|--insecure)\b", "curl invoked with certificate checks off"), + (r"(?:wget)[^\n]*--no-check-certificate", "wget certificate checking disabled"), + (r"ssl\._create_unverified_context", "unverified SSL context created"), + (r"InsecureRequestWarning|disable_warnings", "TLS warnings suppressed"), + (r"CERT_NONE", "certificate validation set to CERT_NONE"), + (r"--no-verify\b|--no-gpg-sign\b", "a commit/push verification step bypassed"), + (r"SKIP\s*=\s*\S+.*(?:secret|talisman|security|audit)", + "a security pre-commit hook added to SKIP"), + (r"(?:#|//)\s*(?:nosec|noqa:\s*S\d|bandit:\s*skip|semgrep-disable|" + r"trivy:ignore|codeql\[[^\]]*\])", "a security linter suppressed inline"), + (r"eslint-disable[^\n]*security", "an ESLint security rule disabled"), + (r"strict\s*[:=]\s*false", "a strict-mode switch disabled"), + (r"trust[_a-z]*\s*[:=]\s*true", "a trust-everything switch enabled"), + (r"allow[_a-z]*(?:insecure|unsafe|any|all)[a-z_]*\s*[:=]\s*true", + "an allow-insecure switch enabled"), + (r"(?:chmod\s+(?:777|a\+rwx))", "world-writable permissions granted"), +] + +# Second tier: patterns that are frequently legitimate but are also exactly how +# a weakening arrives. Blocking on these would make ordinary work impossible, so +# they are REPORTED and the agent judges them in the same turn. Silence would be +# the worse error; a hard failure would be the more annoying one. +ADVISORY_PATTERNS = [ + (r"@ts-(?:ignore|nocheck)", "TypeScript checking suppressed"), + (r"dangerouslySetInnerHTML|\.innerHTML\s*=", "raw HTML injection sink"), + (r"shell\s*=\s*True", "subprocess invoked through a shell"), + (r"\beval\s*\(|\bexec\s*\(|new\s+Function\s*\(", "dynamic code execution"), + (r"pickle\.loads?\s*\(|yaml\.load\s*\((?![^)]*Safe)", "unsafe deserialisation"), + (r"subprocess\.[a-z_]+\([^)]*\+", "subprocess argument built by concatenation"), + (r"(?:SELECT|INSERT|UPDATE|DELETE)\b[^\n]*(?:%s|\+\s*[a-z_]|\bf\")", + "SQL assembled by string building rather than parameterised"), + (r"\ballow_?origins?\s*[:=]\s*\[?\s*['\"]\*", "CORS opened to any origin"), +] + + +# --- self-exemption, by exact identity (generated from one source) --------- +# A guard's own source is the one place in the repository where the pattern it +# forbids MUST appear as a literal: a regex that matches a leaked private key +# necessarily contains a description of a leaked private key. Scanning the +# guards made a freshly bootstrapped repository unable to commit itself. +# +# The exemption is deliberately by EXACT PATH, not by directory prefix. +# Exempting `.agents/hooks/` wholesale would create a blind spot big enough to +# hide a real credential in — the precise failure these guards exist to +# prevent. Every other file under `.agents/`, including new hooks, is scanned +# at full strength, and the fire-proofing fixtures are assembled at runtime +# (see `verify_hooks_fire.py`) rather than exempted. +SELF_EXEMPT_FILES = frozenset({ + ".agents/hooks/secret_scanner.py", + ".agents/hooks/path_scanner.py", + ".agents/hooks/block-secrets.py", + ".agents/hooks/block-absolute-paths.py", + ".agents/hooks/check_security_downgrades.py", + ".agents/hooks/pretool_guard.py", + ".agents/hooks/run_adversarial_audit.py", + ".agents/hooks/verify_hooks_fire.py", + "scripts/check_security_downgrades.py", + "scripts/run_adversarial_audit.py", +}) + + +def _normalise_diff_path(path: str) -> str: + """Strip the `a/` or `b/` prefix git puts on diff headers.""" + path = path.strip() + if path.startswith(("a/", "b/")): + path = path[2:] + return path + + +def _is_self_exempt(path: str) -> bool: + return _normalise_diff_path(path) in SELF_EXEMPT_FILES + + +def get_staged_diff() -> str: + try: + res = subprocess.run( + ["git", "diff", "--cached", "-U0"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True + ) + return res.stdout + except Exception: + return "" + + +def main(): + diff = get_staged_diff() + if not diff: + sys.exit(0) + + added = [] # (path, added line) + current = "?" + skip_file = False + for line in diff.splitlines(): + if line.startswith("+++ "): + current = line[4:].strip() + skip_file = _is_self_exempt(current) + continue + if skip_file: + continue + if line.startswith("+"): + added.append((current, line[1:])) + + violations, advisories = [], [] + for path, text in added: + for pattern, description in SECURITY_DOWNGRADE_PATTERNS: + if re.search(pattern, text, re.IGNORECASE): + violations.append(f" ❌ {description}\n {path}: {text.strip()}") + break + for pattern, description in ADVISORY_PATTERNS: + if re.search(pattern, text, re.IGNORECASE): + advisories.append( + f" ⚠️ {description}\n {path}: {text.strip()}") + break + + if advisories: + print("\n🔍 SECURITY REVIEW REQUESTED (advisory — not a failure):") + for advisory in advisories: + print(advisory) + print("\nThese patterns are often legitimate. Judge each one now, in this " + "turn, and say in the commit or PR why it is safe here.\n") + + if violations: + print("\n🔒 SECURITY GUARD VIOLATION DETECTED:") + for violation in violations: + print(violation) + print("\nAI agents are strictly forbidden from disabling security or " + "authentication features to bypass test/verification roadblocks.") + print("These patterns are deliberately broad: if this is a false positive, " + "say so explicitly rather than rewording the line to slip past the " + "check. Otherwise revert the downgrade and fix the underlying " + "configuration or test issue properly.\n") + sys.exit(1) + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.agents/hooks/check_upstream_alignment.py b/.agents/hooks/check_upstream_alignment.py new file mode 100755 index 0000000..13799ce --- /dev/null +++ b/.agents/hooks/check_upstream_alignment.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +check_upstream_alignment.py +--------------------------- +Deterministic pre-PR check verifying that the local branch is up-to-date with +its base branch. +""" + +import os +import subprocess +import sys + +_ROOT = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True +).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + +# --- this repository's layout, discovered at bootstrap (generated) --------- +# ONE source for the folder lists. Several hooks used to carry their own +# hardcoded copies of a vendor-directory list and of a default-branch list, +# which was both duplication and wrong: a firmware repository vendors into its +# own SDK directory and protects a release branch under a project-specific +# name, and no hardcoded copy could know either. +# +# Every value below comes from the investigation the bootstrap ran against THIS +# repository — not from a default list. Re-run the bootstrap with `--update` +# after the layout changes. + +#: Trees this repository consumes but does not own. Never reformat or edit. +VENDORED_PREFIXES: tuple[str, ...] = () + +#: Branches nobody may commit to directly. Discovered from the remote's own +#: protection settings via `gh`, falling back to the detected base branch. +PROTECTED_BRANCHES: tuple[str, ...] = ( + 'master', +) + +#: The PR base for this repository, recorded once so no script has to guess. +BASE_BRANCH: str = "master" + +#: Directories holding a published interface whose docs must move with it. +INTERFACE_PREFIXES: tuple[str, ...] = () + +#: Where this repository documents that interface. +API_DOC_PATHS: tuple[str, ...] = () + +#: Sources where a raw #RRGGBB literal belongs in a theme token instead. +#: Not QML-only: React, Python UIs and stylesheets hardcode colours too. +THEMEABLE_SUFFIXES: tuple[str, ...] = ( + '.qml', + '.py', + '.css', + '.scss', + '.less', +) + +#: The theme/token definitions themselves — the one place literals belong. +THEME_DEFINITION_FILES: tuple[str, ...] = ( + 'Theme.qml', + 'theme.ts', + 'tokens.css', +) + + +def is_vendored(path: str) -> bool: + return any(path.startswith(prefix) for prefix in VENDORED_PREFIXES) + + +def is_themeable_source(path: str) -> bool: + return (path.endswith(THEMEABLE_SUFFIXES) + and not any(name in path for name in THEME_DEFINITION_FILES)) + + + +def run_cmd(cmd, timeout=5) -> str: + try: + res = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=timeout, + check=True + ) + return res.stdout.strip() + except Exception: + return "" + + +def main(): + # Detect current branch + current_branch = run_cmd(["git", "branch", "--show-current"]) + if not current_branch or current_branch in PROTECTED_BRANCHES: + sys.exit(0) + + # Fetch origin silently with short timeout + run_cmd(["git", "fetch", "origin"], timeout=8) + + # THE base branch, not a guess from a candidate list. Ask git what this + # branch actually tracks first — in a stacked-PR workflow the base is the + # branch below, not the repository's default branch. Only then fall back to + # the base the bootstrap recorded. + upstream = run_cmd(["git", "rev-parse", "--abbrev-ref", + "--symbolic-full-name", "@{upstream}"]) + base_branch = "" + if upstream and upstream != current_branch and not upstream.endswith( + "/" + current_branch): + base_branch = upstream + if not base_branch and run_cmd( + ["git", "rev-parse", "--verify", f"origin/{BASE_BRANCH}"]): + base_branch = f"origin/{BASE_BRANCH}" + + if not base_branch: + print(f"⚠️ BRANCH ALIGNMENT: base branch 'origin/{BASE_BRANCH}' is not " + "available locally; run `git fetch origin` and re-run.") + sys.exit(0) + + # Check how many commits current branch is behind base_branch + behind_count = run_cmd(["git", "rev-list", "--count", f"HEAD..{base_branch}"]) + if behind_count and behind_count.isdigit() and int(behind_count) > 0: + print(f"\n⚠️ BRANCH ALIGNMENT WARNING: current branch " + f"'{current_branch}' is {behind_count} commit(s) behind " + f"'{base_branch}'.") + print(f" Please integrate changes from '{base_branch}' before " + "opening or updating PRs:") + print(f" git merge {base_branch} (or run /sync-base)\n") + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.agents/hooks/compile_aiignore.py b/.agents/hooks/compile_aiignore.py new file mode 100755 index 0000000..f4dfde3 --- /dev/null +++ b/.agents/hooks/compile_aiignore.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +""" +compile_aiignore.py + +Translates `.aiignore` into the exclusion mechanism each agent platform actually +honours. No platform reads `.aiignore` itself — this is what makes it real. + +Verified platform behaviour (do not "simplify" this by writing one file): + * Antigravity / ripgrep-based search -> `.ignore` (a real, honoured filename) + * Claude Code -> permissions.deny "Read(./glob)" rules + in .claude/settings.json + * OpenCode -> permission.read/glob/grep deny map + in opencode.json + * GitHub Copilot -> org-level content exclusion, applied + server side; a repo file cannot do it, + so we emit a paste-ready list + +This edits files it does not own, so two rules are absolute: + + 1. NEVER write over a file that could not be parsed. A syntax error means the + content is unknown, and overwriting it would silently delete whatever was + there — including the security hooks in .claude/settings.json. + 2. NEVER remove an entry this compiler did not create. What it generated last + time is recorded in a side manifest, and only those entries are replaced, + so a hand-written deny rule survives every recompile. + +The manifest is a side file rather than a key inside the platform configs +because those configs are schema-validated — OpenCode rejects unknown keys +inside its `lsp` object, and the same risk applies elsewhere. + +Modes: + (default) regenerate every derived target + --check exit non-zero if any derived target is out of date (pre-commit) +""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +AIIGNORE = ".aiignore" +IGNORE_FILE = ".ignore" +CLAUDE_SETTINGS = ".claude/settings.json" +OPENCODE_CONFIG = "opencode.json" +COPILOT_EXCLUSION = ".github/copilot-content-exclusion.yml" +MANIFEST = ".agents/aiignore-generated.json" + +GENERATED_HEADER = ( + "# GENERATED FROM .aiignore by .agents/hooks/compile_aiignore.py\n" + "# Do not edit this file directly — edit .aiignore and re-run the compiler.\n" +) + +_ROOT = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True +).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + + +class ConfigUnreadable(Exception): + """A file exists but could not be parsed — refuse to touch it.""" + + +def read_patterns(): + """Return (all_lines, positive_patterns). Negations stay in .ignore, where + they are meaningful, but are not turned into deny rules — a deny list has no + way to express 'except this'.""" + try: + lines = Path(AIIGNORE).read_text(encoding="utf-8").splitlines() + except OSError: + return None, [] + patterns = [] + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or stripped.startswith("!"): + continue + patterns.append(stripped) + return lines, patterns + + +def to_glob(pattern: str) -> str: + """Convert a gitignore pattern to a glob usable in deny rules. + + gitignore semantics matter here: a pattern without a leading slash matches + at ANY depth, so `*.pem` must become `**/*.pem`. Compiling it to `*.pem` + would silently protect only the repository root. + """ + raw = pattern.strip() + anchored = raw.startswith("/") + p = raw.lstrip("/").rstrip("/") + if raw.rstrip("/") != raw or raw.endswith("/"): + p = p + "/**" + if not anchored and not p.startswith("**/"): + p = "**/" + p + return p + + +def load_json(path): + """Parse a JSON file. + + A missing file is an empty starting point. A file that exists but does not + parse is a hard error: returning {} here would hand an empty config to the + renderer, and the result would be written straight over the user's file. + """ + try: + text = Path(path).read_text(encoding="utf-8") + except OSError: + return {} + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise ConfigUnreadable( + "{}: {} (line {}, column {})".format(path, exc.msg, exc.lineno, exc.colno) + ) from exc + + +def dump_json(data) -> str: + text = json.dumps(data, indent=2) + "\n" + try: + res = subprocess.run( + ["npx", "--no-install", "prettier", "--stdin-filepath", "config.json"], + input=text, + capture_output=True, + text=True, + ) + if res.returncode == 0 and res.stdout: + return res.stdout + except Exception as err: + sys.stderr.write(f"Prettier fallback: {err}\n") + return text + + +def render_ignore(lines) -> str: + return GENERATED_HEADER + "\n".join(lines) + "\n" + + +def render_claude(patterns, existing, previous): + """Replace only the deny rules this compiler generated last time.""" + settings = json.loads(json.dumps(existing)) if existing else {} + permissions = settings.setdefault("permissions", {}) + generated = ["Read(./{})".format(to_glob(p)) for p in patterns] + + stale = set(previous) + kept = [d for d in permissions.get("deny", []) if d not in stale] + for rule in generated: + if rule not in kept: + kept.append(rule) + permissions["deny"] = kept + return settings, generated + + +def render_opencode(patterns, existing, previous): + """OpenCode evaluates permission rules with LAST MATCHING RULE WINNING, so + the catch-all must come first and the deny rules last. Emitting `**: allow` + after the denies would silently cancel every one of them.""" + config = json.loads(json.dumps(existing)) if existing else {} + permission = config.setdefault("permission", {}) + rules = {to_glob(p): "deny" for p in patterns} + stale = set(previous) + + for tool in ("read", "glob", "grep"): + current = permission.get(tool) + if current is not None and not isinstance(current, dict): + # A scalar ("ask" / "deny") is a deliberate repository-wide posture. + # Replacing it with an allow-by-default map would weaken it. + print("NOTE: permission.{} is {!r}; left unchanged so the existing " + "posture is not weakened.".format(tool, current)) + continue + merged = {"**": "allow"} + if isinstance(current, dict): + merged.update({k: v for k, v in current.items() + if k not in rules and k != "**" and k not in stale}) + merged.update(rules) + permission[tool] = merged + return config, sorted(rules) + + +def render_copilot(patterns) -> str: + entries = "\n".join(' - "/{}"'.format(to_glob(p)) for p in patterns) + return ( + "# GENERATED FROM .aiignore by .agents/hooks/compile_aiignore.py\n" + "#\n" + "# GitHub Copilot content exclusion is applied SERVER SIDE and cannot be\n" + "# configured by a file in the repository. Paste the block below into:\n" + "# GitHub -> Organization (or Repository) Settings -> Copilot ->\n" + "# Content exclusion\n" + "#\n" + "# Until that is done, Copilot will still read these paths.\n" + "\n" + '"*":\n' + entries + "\n" + ) + + +def main(): + check_only = "--check" in sys.argv + lines, patterns = read_patterns() + if lines is None: + if not check_only: + print("No {} found; nothing to compile.".format(AIIGNORE)) + return + + try: + manifest = load_json(MANIFEST) + claude_settings, claude_generated = render_claude( + patterns, load_json(CLAUDE_SETTINGS), manifest.get("claude_deny", [])) + opencode_config, opencode_generated = render_opencode( + patterns, load_json(OPENCODE_CONFIG), manifest.get("opencode_rules", [])) + except ConfigUnreadable as exc: + print("=" * 78) + print("AI EXCLUSION COMPILER ABORTED — a config file could not be parsed") + print(" {}".format(exc)) + print("") + print(" Nothing was written. That file is left exactly as it is, because") + print(" overwriting a config we cannot read would delete whatever it") + print(" contains, including the agent hooks.") + print(" Fix the JSON syntax, then re-run.") + print("=" * 78) + sys.exit(1) + + targets = { + IGNORE_FILE: render_ignore(lines), + CLAUDE_SETTINGS: dump_json(claude_settings), + OPENCODE_CONFIG: dump_json(opencode_config), + COPILOT_EXCLUSION: render_copilot(patterns), + MANIFEST: dump_json({ + "comment": ("Records what compile_aiignore.py generated, so the next " + "run replaces only its own entries and leaves " + "hand-written rules alone. Do not edit."), + "claude_deny": claude_generated, + "opencode_rules": opencode_generated, + }), + } + + stale = [] + for path, content in targets.items(): + try: + current = Path(path).read_text(encoding="utf-8") + except OSError: + current = None + if current == content: + continue + stale.append(path) + if not check_only: + Path(path).parent.mkdir(parents=True, exist_ok=True) + Path(path).write_text(content, encoding="utf-8") + + if check_only and stale: + print("=" * 78) + print("AI EXCLUSION OUT OF DATE") + print(" .aiignore has changed but these derived targets were not regenerated:") + for path in stale: + print(" - {}".format(path)) + print("") + print(" Run: python3 .agents/hooks/compile_aiignore.py") + print(" then stage the regenerated files.") + print("=" * 78) + sys.exit(1) + + if not check_only: + if stale: + print("Compiled {} -> {} pattern(s) into:".format(AIIGNORE, len(patterns))) + for path in stale: + print(" updated {}".format(path)) + print("NOTE: Copilot exclusion is server side — paste {} into the " + "GitHub Copilot content-exclusion settings." + .format(COPILOT_EXCLUSION)) + else: + print("AI exclusion targets already up to date.") + + +if __name__ == "__main__": + main() diff --git a/.agents/hooks/compile_rule_frontmatter.py b/.agents/hooks/compile_rule_frontmatter.py new file mode 100755 index 0000000..2dae1f0 --- /dev/null +++ b/.agents/hooks/compile_rule_frontmatter.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +""" +compile_rule_frontmatter.py — the rule-band compiler. + +The numeric prefix of a rule file IS its load-tier declaration: + + 01-14 always_on managed (regenerated by the bootstrap) + 15-19 always_on repo-custom (never regenerated) + 20-34 glob managed — MUST declare `paths:` + 35-39 glob repo-custom — MUST declare `paths:` + 40-44 model_decision managed + 45-59 model_decision repo-custom + +Authored keys: `description` (all rules), `paths` (glob bands only). +Derived keys: `name` (filename), `trigger` (band), `glob` (joined paths). + +Prefixes 03 and 11 are permanently retired: they were the two historical +collision magnets, and any file carrying them marks a pre-overhaul tree. + +Two kinds of finding, kept apart on purpose: + + BLOCKING decidable from the text — a missing band, a duplicate prefix, a + glob rule with no paths. These exit 1. + ADVISORY heuristics, in `rule_advisories.py` — description quality, unfilled + placeholders, unresolvable skills, enforcement claims. These print + and never fail, because a heuristic that blocks from day one turns + its first false positive into an argument about the checker + instead of an argument about the guidance. + +Modes: + --check validate; exit 1 on any blocking violation (wired into pre-commit) + --write rewrite derived keys in place from the authored ones + --advisory report the heuristic findings only; never exits non-zero +""" + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +_HOOKS_DIR = os.path.abspath(os.path.dirname(__file__)) +if _HOOKS_DIR not in sys.path: + sys.path.insert(0, _HOOKS_DIR) +try: + from rule_advisories import advisories_for_rule, check_layering, skill_search_dirs +except ImportError: # pragma: no cover - advisories are optional, never fatal + advisories_for_rule = check_layering = skill_search_dirs = None + +_ROOT = subprocess.run(["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + +#: Files that orient an agent but must not restate a rule (see check_layering). +ORIENTATION_FILES = ("AGENTS.md", "CLAUDE.md", "GEMINI.md") + +RULES_DIR = Path(".agents/rules") +MANIFEST = Path(".agents/rules-manifest.json") +RETIRED_PREFIXES = {3, 11} + +BANDS = ( + (1, 14, "always_on", "managed"), + (15, 19, "always_on", "custom"), + (20, 34, "glob", "managed"), + (35, 39, "glob", "custom"), + (40, 44, "model_decision", "managed"), + (45, 59, "model_decision", "custom"), +) + + +def band_of(number: int): + for lo, hi, trigger, ownership in BANDS: + if lo <= number <= hi: + return trigger, ownership + return None, None + + +def parse_frontmatter(text: str): + m = re.match(r"^---\n(.*?)\n---\n", text, re.S) + if not m: + return {}, None + fm, keys = m.group(1), {} + current = None + for line in fm.splitlines(): + kv = re.match(r"^([A-Za-z_]+):\s*(.*)$", line) + if kv: + current = kv.group(1) + value = kv.group(2).strip().strip("\"'") + keys[current] = [] if value == "" else value + elif re.match(r"^\s+-\s+", line) and isinstance(keys.get(current), list): + keys[current].append(line.split("-", 1)[1].strip().strip("\"'")) + return keys, m + + +def derived_frontmatter(path: Path, keys: dict, trigger: str) -> str: + stem = re.sub(r"^\d{2}-", "", path.stem.removesuffix("-rules")) + lines = [f"name: {stem}"] + description = keys.get("description") + if description: + lines.append(f"description: {description}") + lines.append(f"trigger: {trigger}") + paths = keys.get("paths") or [] + if paths: + lines.append("glob: \"" + ",".join(paths) + "\"") + lines.append("paths:") + lines += [f" - \"{p}\"" for p in paths] + return "---\n" + "\n".join(lines) + "\n---\n" + + +def check_rule(path: Path, manifest: dict, errors: list): + m = re.match(r"^(\d{2})-", path.name) + if not m: + errors.append(f"{path.name}: no numeric prefix — every rule declares " + "its load tier through its number") + return None + number = int(m.group(1)) + if number in RETIRED_PREFIXES: + errors.append(f"{path.name}: prefix {number:02d} is permanently " + "retired; renumber into the correct band") + return None + trigger, ownership = band_of(number) + if trigger is None: + errors.append(f"{path.name}: prefix {number:02d} is outside every " + "band (01-14, 15-19, 20-34, 35-39, 40-44, 45-59)") + return None + keys, _ = parse_frontmatter(path.read_text(errors="ignore")) + if not keys.get("description"): + errors.append(f"{path.name}: missing description: — a rule without " + "one is not lazily loaded, it is undiscoverable") + paths_declared = bool(keys.get("paths")) + if trigger == "glob" and not paths_declared: + errors.append(f"{path.name}: glob-band rule declares no paths: — " + "its scope can never fire") + if trigger != "glob" and paths_declared: + errors.append(f"{path.name}: paths: declared outside the glob band — " + "the scope can never be consulted") + if ownership == "managed" and manifest and path.name not in manifest: + errors.append( + f"{path.name}: hand-authored file in MANAGED band space " + f"({trigger} managed). It will be overwritten or deleted by the " + "next --update. Renumber to 15-19, 35-39 or 45-59.") + return number, trigger, keys + + +def report_advisories() -> None: + """Print the heuristic findings. Never fails: see the module docstring.""" + if advisories_for_rule is None: + return + search_dirs = skill_search_dirs() + findings = [] + for path in sorted(RULES_DIR.glob("*.md")): + if path.is_symlink(): + continue + text = path.read_text(errors="ignore") + keys, match = parse_frontmatter(text) + body = text[match.end():] if match else text + stem = re.sub(r"^\d{2}-", "", path.stem.removesuffix("-rules")) + description = keys.get("description") + if not isinstance(description, str): + description = "" + findings += [(path, msg) for msg in + advisories_for_rule(stem, description, body, search_dirs)] + orientation = [Path(name) for name in ORIENTATION_FILES] + findings += check_layering(orientation) + if not findings: + return + print("Rule advisories ({}) — reported, not blocking:".format(len(findings))) + for path, message in findings: + print(" {}: {}".format(path, message)) + + +def main(): + if "--advisory" in sys.argv: + if RULES_DIR.is_dir(): + report_advisories() + return 0 + mode = "--check" if "--check" in sys.argv else ( + "--write" if "--write" in sys.argv else "--check") + if not RULES_DIR.is_dir(): + return 0 + manifest = {} + if MANIFEST.exists(): + try: + manifest = json.loads(MANIFEST.read_text()).get("rules", {}) + except (ValueError, OSError): + pass + errors, seen = [], {} + for path in sorted(RULES_DIR.glob("*.md")): + if path.is_symlink(): + continue + result = check_rule(path, manifest, errors) + if not result: + continue + number, trigger, keys = result + if number in seen: + errors.append(f"{path.name}: duplicate numeric prefix with " + f"{seen[number]} — no multi-file slots exist") + seen[number] = path.name + if mode == "--write": + text = path.read_text(errors="ignore") + keys, m = parse_frontmatter(text) + new_fm = derived_frontmatter(path, keys, trigger) + body = text[m.end():] if m else text + path.write_text(new_fm + body) + if errors: + print("Rule frontmatter compile FAILED:", file=sys.stderr) + for err in errors: + print(f" - {err}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/hooks/copilot_index.py b/.agents/hooks/copilot_index.py new file mode 100755 index 0000000..d49b338 --- /dev/null +++ b/.agents/hooks/copilot_index.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +""" +copilot_index.py + +The Copilot rule index — read, rebuild and verify — with `.agents/rules/` as +the single source of truth. + +Copilot has no rule-directory convention of its own: `.github/copilot-instructions.md` +is the only place it learns which rules exist, and it learns them from a list of +markdown links. That list was rendered once, at bootstrap time, from the rules +*that run produced* — so a repo-owned rule (bands 15-19/35-39/45-59) or any rule +added afterwards never reached Copilot at all, and nothing noticed, because the +parity audit only ever checked that a listed rule exists and never that an +existing rule is listed. + +This module owns both directions: + + * `listed_rules()` / `canonical_rules()` — the two sets the audit compares. + * `refresh_text()` — rebuild the index block from the rules on disk, so the + sync script can bring a stale file back into line without a re-bootstrap. + +Run it directly as the pre-commit gate (`--check`) or as the refresher (no +arguments). `scripts/sync_agentic_configs.sh` calls the refresher. +""" + +import re +import sys +from pathlib import Path + +INDEX_REL = ".github/copilot-instructions.md" +RULES_REL = ".agents/rules" +HEADING = "## Rule Index" + +# The link form the index template emits. Matched rather than assumed so a +# hand-widened index (extra prose after the link) still parses. +_LINK_RE = re.compile(r"\]\(\.\./\.agents/rules/([\w.-]+\.md)\)") + + +def canonical_rules(repo: Path) -> set: + """Every rule file the repository actually has. + + Symlinks are skipped: `.claude/rules` and `.opencode/rules` mirror this + directory, and a mirror that leaked back in would be counted twice. + """ + rules_dir = repo / RULES_REL + if not rules_dir.is_dir(): + return set() + return {p.name for p in rules_dir.glob("*.md") if not p.is_symlink()} + + +def listed_rules(text: str) -> set: + """Every rule the Copilot index points at.""" + return set(_LINK_RE.findall(text)) + + +def rule_label(filename: str) -> str: + return filename.removesuffix(".md").removesuffix("-rules") + + +def index_lines(filenames) -> list: + return [f"- [{rule_label(name)}](../{RULES_REL}/{name})" + for name in sorted(filenames)] + + +def _block_bounds(lines: list) -> tuple: + """(start, end) of the index list body, or (-1, -1) when there is none. + + `start` is the first line after the heading; `end` is exclusive and stops + at the first line that is neither blank nor a rule link — the "Hard + constraints" paragraph that follows the list in the generated file. + """ + try: + head = lines.index(HEADING) + except ValueError: + return -1, -1 + start = head + 1 + end = start + for i in range(start, len(lines)): + line = lines[i] + if not line.strip() or _LINK_RE.search(line): + end = i + 1 + continue + break + # Trim trailing blank lines back out of the block so the rebuild does not + # accumulate one blank line per run. + while end > start and not lines[end - 1].strip(): + end -= 1 + return start, end + + +def refresh_text(text: str, filenames) -> str: + """Return `text` with its rule index replaced by `filenames`. + + An index with no `## Rule Index` heading is a file this generator does not + recognise — a hand-written Copilot instruction sheet, for instance. Its + content is never rewritten; the section is appended instead, so Copilot + gains the rules without the repository losing its prose. + """ + lines = text.splitlines() + body = [""] + index_lines(filenames) + start, end = _block_bounds(lines) + if start < 0: + tail = lines + ([""] if lines and lines[-1].strip() else []) + return "\n".join(tail + [HEADING] + body).rstrip("\n") + "\n" + tail = lines[end:] + # One blank line separates the list from whatever follows — added only when + # the tail does not already start with one, or every run gains a line. + if tail and tail[0].strip(): + body.append("") + rebuilt = lines[:start] + body + tail + return "\n".join(rebuilt).rstrip("\n") + "\n" + + +def _default_index(filenames) -> str: + """A minimal index for a repository that has none yet. The bootstrap writes + a fuller pointer file; this exists so the refresher is never the reason a + repository has no Copilot entry point at all.""" + return refresh_text( + "\n\n" + "# GitHub Copilot Repository Instructions\n\n" + "The single source of truth for agent behavior is " + "[AGENTS.md](../AGENTS.md) plus the numbered rule files in " + "[.agents/rules/](../.agents/rules/).\n", + filenames) + + +def check(repo: Path) -> list: + """Problems with the index, as human-readable lines. Empty means healthy.""" + canonical = canonical_rules(repo) + if not canonical: + return [] + index = repo / INDEX_REL + if not index.exists(): + return [f"{INDEX_REL} is missing — Copilot has no rule index at all"] + listed = listed_rules(index.read_text(encoding="utf-8", errors="ignore")) + problems = [f"{INDEX_REL} does not list {name} — Copilot never sees this rule" + for name in sorted(canonical - listed)] + problems += [f"{INDEX_REL} links {name}, which does not exist" + for name in sorted(listed - canonical)] + return problems + + +def refresh(repo: Path) -> bool: + """Rewrite the index from `.agents/rules/`. True when the file changed.""" + canonical = canonical_rules(repo) + if not canonical: + return False + index = repo / INDEX_REL + if index.exists(): + current = index.read_text(encoding="utf-8", errors="ignore") + updated = refresh_text(current, canonical) + else: + current, updated = "", _default_index(canonical) + if updated == current: + return False + index.parent.mkdir(parents=True, exist_ok=True) + index.write_text(updated, encoding="utf-8") + return True + + +def main() -> int: + args = [a for a in sys.argv[1:] if a != "--check"] + repo = Path(args[0]) if args else Path(".") + if "--check" in sys.argv[1:]: + problems = check(repo) + for problem in problems: + print(f"[X] {problem}") + if problems: + print(" Run: python3 .agents/hooks/copilot_index.py") + return 1 + return 0 + if refresh(repo): + print(f"Refreshed {INDEX_REL} from {RULES_REL}/") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/hooks/file_size_baseline.py b/.agents/hooks/file_size_baseline.py new file mode 100755 index 0000000..315bed5 --- /dev/null +++ b/.agents/hooks/file_size_baseline.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +""" +file_size_baseline.py + +The ratchet's *state*: reading and writing .agents/file-size-baseline.json, measuring a +file, and deciding whether a merge legitimately raises a ceiling. + +Split out of check_file_size_budget.py, which had grown past the very budget it +enforces. Rule 10 applies to the tool that enforces rule 10 — and this is not a +cosmetic split: baseline persistence and merge-parent reconciliation change for +entirely different reasons than the command-line surface does. + +The third module is file_size_scope.py, which decides WHICH files the budget +covers. Reading a file's head to classify it belongs there, next to the rules +that judge what it finds — not here, where measuring is about counting lines. +""" + +import json +import subprocess +from pathlib import Path + +BUDGET = 400 +BASELINE_PATH = ".agents/file-size-baseline.json" + + +def count_lines(path: str, staged: bool = False): + """Count lines in the content that is actually being judged. + + Under --staged that is the INDEX copy, not the working tree. Selecting + paths from the index while measuring the working tree lets an oversized + file through and records a ceiling the commit never met. + """ + if staged: + result = subprocess.run(["git", "show", ":{}".format(path)], + capture_output=True) + if result.returncode == 0: + return result.stdout.count(b"\n") + ( + 0 if result.stdout.endswith(b"\n") or not result.stdout else 1) + return None + try: + with open(path, "rb") as handle: + return sum(1 for _ in handle) + except OSError: + return None + + +def load_baseline() -> dict: + try: + with open(BASELINE_PATH, encoding="utf-8") as handle: + return json.load(handle) + except (OSError, json.JSONDecodeError): + return {"budget": BUDGET, "policy": "ratchet", "files": {}} + + +def save_baseline(baseline: dict) -> None: + with open(BASELINE_PATH, "w", encoding="utf-8") as handle: + json.dump(baseline, handle, indent=2, sort_keys=True) + handle.write("\n") + + +def git_files(*args: str): + res = subprocess.run(["git", *args], capture_output=True, text=True) + if res.returncode != 0: + return [] + return [f for f in res.stdout.splitlines() if f.strip()] + + +def raise_ceilings_for_merge(baseline: dict) -> None: + """During a merge, accept growth the incoming branch already had approved. + + A baseline seeded before an upstream merge re-litigates that merge: the + incoming side legitimately grew a grandfathered file, its own PR gated that + growth, and the merge commit then fails for code this change never wrote. + So for a merge commit only, each ceiling rises to the largest size among + the merge parents. Growth introduced *by the resolution itself* still fails, + because that exceeds every parent. + """ + merge_head = Path(".git") / "MERGE_HEAD" + if not merge_head.exists(): + return + try: + parents = ["HEAD"] + merge_head.read_text().split() + except OSError: + return + raised = [] + for name, ceiling in list(baseline.get("files", {}).items()): + for rev in parents: + try: + blob = subprocess.run(["git", "show", f"{rev}:{name}"], + capture_output=True, text=True, check=True) + except (subprocess.CalledProcessError, OSError): + continue + size = len(blob.stdout.splitlines()) + if size > baseline["files"][name]: + baseline["files"][name] = size + raised.append((name, ceiling, size)) + for name, was, now in raised: + print(f"File-size ratchet: merge raises the ceiling for {name} " + f"({was} -> {now}); the incoming branch already gated that growth.") + + +def commit_baseline(baseline: dict, grandfathered: dict) -> None: + baseline["files"] = grandfathered + save_baseline(baseline) + subprocess.run(["git", "add", BASELINE_PATH], check=False) diff --git a/.agents/hooks/file_size_scope.py b/.agents/hooks/file_size_scope.py new file mode 100755 index 0000000..868a47e --- /dev/null +++ b/.agents/hooks/file_size_scope.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +""" +file_size_scope.py + +Which files the file-size budget covers, and why any one of them is exempt. + +This is the CLASSIFICATION third of the ratchet. `file_size_baseline.py` holds +its state, `check_file_size_budget.py` its command-line surface, and this file +decides what is in scope at all. The three change for entirely different +reasons: this one when the repository's idea of "not ours to decompose" +changes, the others when the bookkeeping or the CLI does. + +WHAT WENT WRONG BEFORE (upstream TT-1399). The vendor test was a bare substring +match: any file whose first 4000 bytes contained a vendor name ANYWHERE — in a +comment, in a string, or in a constant that merely lists the names — dropped +out of the budget silently. A 500-line first-party file whose first line was a +three-letter vendor name in a comment was not measured at all, while an +identical file without it was. Worse, the ratchet's own source listed every +vendor name it knows, so once it grew past 400 lines it exempted ITSELF from +the budget it enforces. An audit of the whole tree under the old rule found +exactly one file the vendor branch had ever exempted: the gate. + +WHAT IS DIFFERENT NOW. Every content-based exemption needs TWO independent +signals, not one. A vendor name only counts on a line that is also shaped like +a copyright notice: it carries a notice word or symbol AND an ownership claim +(a year, or "all rights reserved"). A generated marker only counts inside a +directory that is plausibly generated output — which the generated branch +already required, and which is the shape the vendor rule now mirrors. + +Requiring a notice rather than a path is deliberate. Vendored code arrives +inside otherwise first-party directories, so gating the content test on path as +well would leave it able to fire almost nowhere, and EXCLUDED_PREFIXES already +covers the vendored trees that do sit at a recognisable path. A first-party +file cannot honestly carry another company's copyright notice, so the notice is +the corroboration. + +Every decision this module makes is REPORTABLE, not just a boolean: callers get +an `Exemption` naming the kind and the reason, so a file that was never +measured can be told apart from one that was measured and passed. That +distinction is the other half of the fix — the old `--report` output said +"unchecked suffix" for a `.py` file whose suffix was perfectly fine. +""" + +from __future__ import annotations + +import re +import subprocess +from dataclasses import dataclass + +HEAD_BYTES = 4000 + +# Rendered from the detected stacks. Hardcoding `(".py",)` here made the +# file-size rule completely inert in every C and C++ repository the bootstrap +# ever touched: the hook ran, found nothing it recognised, and reported success. +CHECKED_SUFFIXES: tuple[str, ...] = ( + '.py', +) +EXCLUDED_PREFIXES: tuple[str, ...] = ( + '.agents/', + 'build/', + 'tests/', + 'test/', +) +#: Matched anywhere in the path, for vendored trees nested inside the project. +EXCLUDED_FRAGMENTS: tuple[str, ...] = () + +#: Third-party names. A name alone proves nothing — see VENDOR_NOTICE_WORD. +VENDOR_NAMES: tuple[bytes, ...] = ( + b'nxp', + b'freescale', + b'arm limited', + b'st microelectronics', + b'stmicroelectronics', + b'texas instruments', + b'microchip', + b'silicon laboratories', + b'nordic semiconductor', + b'the qt company', + b'cmsis', +) + +# The two signals a line must carry before a vendor name on it counts as a +# third-party copyright notice. Both are needed: the word on its own appears in +# ordinary prose and in identifiers, and a bare year appears in every changelog. +VENDOR_NOTICE_WORD = re.compile(rb"copyright|\(c\)|\xc2\xa9") +VENDOR_NOTICE_CLAIM = re.compile(rb"\b(19|20)\d{2}\b|all rights reserved") + +GENERATED_MARKERS = ( + b"@generated", b"do not edit", b"do not modify", b"automatically generated", + b"auto-generated", b"autogenerated", b"generated by", +) + +# A marker alone cannot exempt a file. The exemption exists for vendored and +# machine-generated trees; if one comment were sufficient, an agent that has +# read the rule could opt out of the budget in a single line — and the rule +# text tells it exactly which line. So a marker only counts inside a location +# that is plausibly not ours. +GENERATED_LOCATION = re.compile( + r"(^|/)(vendor|third_party|node_modules|generated|gen|__generated__|" + r"autogen|golden|goldens|snapshots|__snapshots__|baseline|baselines|" + r"externals?|deps|_deps|dist|build|proto|protos|sdk)(/|$)") + +# The generated trees THIS repository has, from the same detector the budget +# exclusion and the pre-commit style exclusion read. A name list can only ever +# cover the universal conventions; `src/proto_gen/` is generated output under a +# name no list would guess, and only the detector knows that. +GENERATED_PREFIXES: tuple[str, ...] = () + + +@dataclass(frozen=True) +class Exemption: + """Why a file is outside the budget, in a form a report can print.""" + + kind: str + detail: str + + def __str__(self) -> str: + return "{} — {}".format(self.kind, self.detail) + + @property + def content_addressed(self) -> bool: + """Whether this verdict came from the file's bytes rather than its path. + + A path-shaped exemption is visible in any listing; a content-addressed + one is invisible unless something says it out loud, which is the failure + this module was rewritten to prevent. + """ + return self.kind in ("vendored", "generated") + + +def read_head(path: str, staged: bool = False) -> bytes: + """The first HEAD_BYTES of the content actually being judged.""" + if staged: + result = subprocess.run(["git", "show", ":{}".format(path)], + capture_output=True) + return result.stdout[:HEAD_BYTES] if result.returncode == 0 else b"" + try: + with open(path, "rb") as handle: + return handle.read(HEAD_BYTES) + except OSError: + return b"" + + +def _unchecked_suffix(path: str, extra_excludes) -> Exemption | None: + if CHECKED_SUFFIXES and path.endswith(CHECKED_SUFFIXES): + return None + return Exemption( + "unchecked-suffix", + "the budget covers {} only".format(", ".join(CHECKED_SUFFIXES))) + + +def _excluded_prefix(path: str, extra_excludes) -> Exemption | None: + for prefix in EXCLUDED_PREFIXES: + if path.startswith(prefix): + return Exemption("excluded-path", + "under the excluded prefix '{}'".format(prefix)) + return None + + +def _excluded_fragment(path: str, extra_excludes) -> Exemption | None: + for fragment in EXCLUDED_FRAGMENTS: + if fragment and fragment in path: + return Exemption("excluded-path", + "matches the excluded fragment '{}'".format(fragment)) + return None + + +def _baseline_exclude(path: str, extra_excludes) -> Exemption | None: + for fragment in extra_excludes: + if fragment and fragment in path: + return Exemption( + "excluded-path", + "matches '{}' in the baseline's exclude list".format(fragment)) + return None + + +def _vendor_copyright(path: str, head: bytes) -> Exemption | None: + """A third-party copyright notice: self-evidently not ours to refactor. + + The name must sit on a line that IS a notice, not merely a line that names + a company. That corroboration is the whole fix; without it any mention + anywhere in the head was enough, including this module's own list. + """ + for line in head.splitlines(): + if not (VENDOR_NOTICE_WORD.search(line) and VENDOR_NOTICE_CLAIM.search(line)): + continue + for name in VENDOR_NAMES: + if name in line: + return Exemption( + "vendored", + "third-party copyright notice for '{}'".format(name.decode())) + return None + + +def _in_generated_location(path: str) -> bool: + return bool(GENERATED_LOCATION.search(path)) or path.startswith( + GENERATED_PREFIXES) + + +def _generated_output(path: str, head: bytes) -> Exemption | None: + for marker in GENERATED_MARKERS: + if marker in head and _in_generated_location(path): + return Exemption( + "generated", + "'{}' marker in a generated location".format(marker.decode())) + return None + + +# Ordered registries rather than a chain of ifs: a further exemption is an entry +# here, testable on its own, and the reporting side needs no edit at all. Path +# rules run first because they are free — the content rules need the file's +# bytes, which under --staged means a subprocess per file. +PATH_RULES = (_unchecked_suffix, _excluded_prefix, _excluded_fragment, + _baseline_exclude) +CONTENT_RULES = (_vendor_copyright, _generated_output) + + +def exemption_for(path: str, extra_excludes=(), staged: bool = False): + """Why `path` is outside the file-size budget, or None if it is covered.""" + for rule in PATH_RULES: + verdict = rule(path, extra_excludes) + if verdict is not None: + return verdict + head = read_head(path, staged).lower() + if not head: + return None + for rule in CONTENT_RULES: + verdict = rule(path, head) + if verdict is not None: + return verdict + return None + + +def is_checked(path: str, extra_excludes=(), staged: bool = False) -> bool: + """Whether the file-size budget applies to `path`.""" + return exemption_for(path, extra_excludes, staged) is None diff --git a/.agents/hooks/git-branch-guard.py b/.agents/hooks/git-branch-guard.py new file mode 100755 index 0000000..9064506 --- /dev/null +++ b/.agents/hooks/git-branch-guard.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Refuse commits on branches this repository actually protects. + +The protected list is NOT `["main", "master", "staging"]`. That guess was +simultaneously too wide (forbidding `master` in a repository that has none) and +too narrow (waving through a direct commit to the release branch this project +actually protects). The bootstrap asks GitHub for the real answer via +`gh api repos/{owner}/{repo}/branches` and renders it below. +""" + +import os +import re +import subprocess +import sys + +# Hook may be invoked from .agents/ (Antigravity sets cwd to the hooks.json +# directory) — always operate from the repository root. +_ROOT = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True +).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + +# --- this repository's layout, discovered at bootstrap (generated) --------- +# ONE source for the folder lists. Several hooks used to carry their own +# hardcoded copies of a vendor-directory list and of a default-branch list, +# which was both duplication and wrong: a firmware repository vendors into its +# own SDK directory and protects a release branch under a project-specific +# name, and no hardcoded copy could know either. +# +# Every value below comes from the investigation the bootstrap ran against THIS +# repository — not from a default list. Re-run the bootstrap with `--update` +# after the layout changes. + +#: Trees this repository consumes but does not own. Never reformat or edit. +VENDORED_PREFIXES: tuple[str, ...] = () + +#: Branches nobody may commit to directly. Discovered from the remote's own +#: protection settings via `gh`, falling back to the detected base branch. +PROTECTED_BRANCHES: tuple[str, ...] = ( + 'master', +) + +#: The PR base for this repository, recorded once so no script has to guess. +BASE_BRANCH: str = "master" + +#: Directories holding a published interface whose docs must move with it. +INTERFACE_PREFIXES: tuple[str, ...] = () + +#: Where this repository documents that interface. +API_DOC_PATHS: tuple[str, ...] = () + +#: Sources where a raw #RRGGBB literal belongs in a theme token instead. +#: Not QML-only: React, Python UIs and stylesheets hardcode colours too. +THEMEABLE_SUFFIXES: tuple[str, ...] = ( + '.qml', + '.py', + '.css', + '.scss', + '.less', +) + +#: The theme/token definitions themselves — the one place literals belong. +THEME_DEFINITION_FILES: tuple[str, ...] = ( + 'Theme.qml', + 'theme.ts', + 'tokens.css', +) + + +def is_vendored(path: str) -> bool: + return any(path.startswith(prefix) for prefix in VENDORED_PREFIXES) + + +def is_themeable_source(path: str) -> bool: + return (path.endswith(THEMEABLE_SUFFIXES) + and not any(name in path for name in THEME_DEFINITION_FILES)) + + +# `KEY-123-short-description`: hyphens throughout, never underscores. A branch +# named `UC-123_short_description` reads as one token to every tool +# that splits on `-`, and the convention here is hyphenated. +BRANCH_NAME_RE = re.compile(r"^UC-\d+-[a-z0-9]+(-[a-z0-9]+)*$", re.I) + + +def check_branch(): + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + text=True, + ) + branch = result.stdout.strip() + if branch in PROTECTED_BRANCHES: + print( + f"BRANCH GUARD ERROR: '{branch}' is a protected branch in this " + f"repository (protected: {', '.join(PROTECTED_BRANCHES)}).\n" + f"Create a feature branch: git switch -c UC-123-short-description" + ) + sys.exit(1) + + if branch and not BRANCH_NAME_RE.match(branch): + print( + f"BRANCH NAMING NOTICE: '{branch}' does not match " + f"`UC--short-description`.\n" + " Use hyphens, not underscores, between every part of the name.\n" + " This is advisory — rename with `git branch -m` if the branch is " + "not yet published." + ) + + +if __name__ == "__main__": + check_branch() diff --git a/.agents/hooks/path_scanner.py b/.agents/hooks/path_scanner.py new file mode 100755 index 0000000..90a937f --- /dev/null +++ b/.agents/hooks/path_scanner.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +import os +import re +import subprocess +import sys +from pathlib import Path, PurePosixPath + +# --- shared detection patterns (generated from ONE source) ----------------- +# Every guard that scans content imports these: the pre-flight guard +# (`pretool_guard.py`), the two pre-commit scanners (via `secret_scanner.py` +# and `path_scanner.py`), and the pre-PR gate (`run_adversarial_audit.py`). +# +# WHY THIS PARTIAL EXISTS. Downstream, four guards each carried their own copy +# under a comment claiming the copies were "generated from one source" and so +# could not drift apart. There was no source, and they HAD drifted: the pre-PR +# gate held four of the eight secret shapes — missing PKCS#8 private keys and +# the Slack, OpenAI and AWS credentials — and its GitHub regex matched only +# `ghp_` rather than the whole `gh[pousr]_` family that `gh auth` mints. A +# security policy is knowledge, so it is written once, here, and included. +# +# Do NOT re-declare a credential shape at a call site. Add it here. +# +# One caution for whoever edits this next: a pattern written as a literal token +# rather than a prefix plus a character class would make this catalogue match +# its own source, and the scanners would block the commit that adds it. None of +# the shapes below do — `[` sits outside every character class — and the +# two files this partial is inlined into (`secret_scanner.py`, +# `path_scanner.py`) are already listed in `_self_exempt.py.j2`, so a future +# literal example cannot turn the catalogue into the thing it blocks. + +SECRET_PATTERNS = [ + # PKCS#1 / OpenSSH / PGP and the PKCS#8 forms that `openssl genpkey` and + # `ssh-keygen -m PKCS8` emit by default — the latter were previously missed. + re.compile(r"-----BEGIN (?:RSA|OPENSSH|DSA|EC|PGP) PRIVATE KEY-----"), + re.compile(r"-----BEGIN(?: ENCRYPTED)? PRIVATE KEY-----"), + re.compile(r"AIzaSy[A-Za-z0-9_-]{33}"), # Google API key + # The whole `gh` token family, not just `ghp_`: `gh auth` mints `gho_`, + # `ghu_`, `ghs_` and `ghr_` too, and each is equally a live credential. + re.compile(r"gh[pousr]_[A-Za-z0-9]{36,}"), # GitHub tokens + # GitHub fine-grained PAT + re.compile(r"github_pat_[0-9a-zA-Z]{22}_[0-9a-zA-Z]{59}"), + re.compile(r"glpat-[A-Za-z0-9_-]{20}"), # GitLab PAT + re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), # Slack + re.compile(r"sk-[A-Za-z0-9]{32,}"), # OpenAI-style + re.compile(r"\bAKIA[0-9A-Z]{16}\b"), # AWS access key id + # AWS secret keys carry no prefix, so they are only recognisable next to + # the assignment that names them. + re.compile(r"(?i)aws_secret_access_key\s*=\s*['\"]?([0-9a-zA-Z/+]{40})['\"]?"), +] + +HOME_PATH_PATTERN = re.compile(r"/home/[a-zA-Z0-9_-]+/") +USERS_PATH_PATTERN = re.compile(r"/Users/[a-zA-Z0-9_-]+/") +ABSOLUTE_PATH_PATTERNS = [HOME_PATH_PATTERN, USERS_PATH_PATTERN] + + +# --- self-exemption, by exact identity (generated from one source) --------- +# A guard's own source is the one place in the repository where the pattern it +# forbids MUST appear as a literal: a regex that matches a leaked private key +# necessarily contains a description of a leaked private key. Scanning the +# guards made a freshly bootstrapped repository unable to commit itself. +# +# The exemption is deliberately by EXACT PATH, not by directory prefix. +# Exempting `.agents/hooks/` wholesale would create a blind spot big enough to +# hide a real credential in — the precise failure these guards exist to +# prevent. Every other file under `.agents/`, including new hooks, is scanned +# at full strength, and the fire-proofing fixtures are assembled at runtime +# (see `verify_hooks_fire.py`) rather than exempted. +SELF_EXEMPT_FILES = frozenset({ + ".agents/hooks/secret_scanner.py", + ".agents/hooks/path_scanner.py", + ".agents/hooks/block-secrets.py", + ".agents/hooks/block-absolute-paths.py", + ".agents/hooks/check_security_downgrades.py", + ".agents/hooks/pretool_guard.py", + ".agents/hooks/run_adversarial_audit.py", + ".agents/hooks/verify_hooks_fire.py", + "scripts/check_security_downgrades.py", + "scripts/run_adversarial_audit.py", +}) + + +def _normalise_diff_path(path: str) -> str: + """Strip the `a/` or `b/` prefix git puts on diff headers.""" + path = path.strip() + if path.startswith(("a/", "b/")): + path = path[2:] + return path + + +def _is_self_exempt(path: str) -> bool: + return _normalise_diff_path(path) in SELF_EXEMPT_FILES + + +class PathScanner: + """Absolute user-home paths, reported with the offending path itself. + + Structural parsing rather than a bare regex: `PurePosixPath` tells us + whether the token really is an absolute path under a user home, which is + what lets the message name the path a developer has to remove instead of + only the line it sat on. + + The shared catalogue (`ABSOLUTE_PATH_PATTERNS`) is still consulted, as a + cross-check in the one direction that matters. If the catalogue recognises + a shape the structural extractor missed, that is a FALSE NEGATIVE in this + scanner, and a silently unenforced rule is worse than a noisy one — so the + line is reported anyway and the mismatch is called out by name. + """ + + PATH_CANDIDATE_REGEX = re.compile( + r"(?:/(?:[a-zA-Z0-9_.-]+/)+[a-zA-Z0-9_.-]*" + r"|/home/[a-zA-Z0-9_-]+|/Users/[a-zA-Z0-9_-]+)" + ) + + @classmethod + def extract_path_candidates(cls, text: str) -> list[str]: + return cls.PATH_CANDIDATE_REGEX.findall(text) + + @classmethod + def evaluate_path_string(cls, candidate: str) -> dict: + is_absolute = False + is_forbidden_user_path = False + try: + p = PurePosixPath(candidate) + if p.is_absolute(): + is_absolute = True + parts = p.parts + if (len(parts) >= 3 and parts[0] == "/" + and parts[1] in ("home", "Users")): + is_forbidden_user_path = True + except (ValueError, TypeError): + pass + return { + "is_absolute": is_absolute, + "is_forbidden_user_path": is_forbidden_user_path, + } + + @classmethod + def scan_line(cls, line: str) -> tuple[bool, list[str]]: + """(violation_found, offending_paths) for a single line of content.""" + bad = [c for c in cls.extract_path_candidates(line) + if cls.evaluate_path_string(c)["is_forbidden_user_path"]] + if bad: + return True, bad + # Catalogue cross-check. Reaching here means the shared patterns saw a + # user-home path that structural extraction did not; report it rather + # than let it through, and name the gap so it gets closed. + for pattern in ABSOLUTE_PATH_PATTERNS: + match = pattern.search(line) + if match: + return True, ["{} (matched by the shared catalogue only — " + "PathScanner.extract_path_candidates missed it)" + .format(match.group(0))] + return False, [] + + @classmethod + def check_file_existence(cls, rel_path_str: str, root_dir: Path = None) -> bool: + root = root_dir or Path(os.getcwd()) + try: + target = (root / rel_path_str).resolve() + return target.exists() + except (OSError, ValueError): + return False + + @classmethod + def scan_staged(cls) -> bool: + diff_cmd = subprocess.run( + ["git", "diff", "--cached", "-U0"], + capture_output=True, + text=True, + check=False, + ) + if diff_cmd.returncode != 0: + return False + + found_forbidden = False + skip_file = False + for line in diff_cmd.stdout.splitlines(): + if line.startswith("+++ "): + target = line[4:].strip() + skip_file = _is_self_exempt(target) + continue + if skip_file: + continue + if line.startswith("+"): + # Routed through scan_line so the staged-commit gate and the + # pre-flight guard (which calls scan_line directly) can never + # disagree about what counts as a violation. + violated, offenders = cls.scan_line(line[1:]) + for candidate in offenders: + print( + "❌ PathScanner blocked commit: detected hardcoded " + f"user path: {candidate}", + file=sys.stderr, + ) + found_forbidden = found_forbidden or violated + + return found_forbidden diff --git a/.agents/hooks/post-edit-linter.sh b/.agents/hooks/post-edit-linter.sh new file mode 100755 index 0000000..11417dd --- /dev/null +++ b/.agents/hooks/post-edit-linter.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Auto-generated by ultimaker-agentic-bootstrap from detected repo tooling. +# Runs the repository's own linters/formatters on files changed since HEAD. +set -uo pipefail + +# Antigravity invokes hooks with cwd set to the hooks.json directory — +# always operate from the repository root. +cd "$(git rev-parse --show-toplevel)" || exit 0 + +FAIL=0 + +# Lint what this edit actually wrote, not everything the branch has touched. +# A PostToolUse payload names the edited file on stdin; when it does, that one +# path is the deliverable. Linting `git diff --name-only HEAD` on EVERY edit +# re-reported the same findings about untouched files over and over — which is +# how hook output stops being read — and its cost grew with the length of the +# branch rather than the size of the edit. +# +# Explicit arguments win over the payload, and the whole working diff remains +# the fallback, so nothing is skipped when no path can be determined. +CHANGED="" +if [ "$#" -gt 0 ]; then + CHANGED=$(printf '%s\n' "$@") +elif [ ! -t 0 ]; then + CHANGED=$(python3 -c ' +import json, sys +try: + payload = json.load(sys.stdin) +except Exception: + sys.exit(0) +inp = payload.get("tool_input") or {} +seen = [] +for key in ("file_path", "notebook_path"): + value = inp.get(key) + if isinstance(value, str) and value: + seen.append(value) +for edit in inp.get("edits") or []: + value = (edit or {}).get("file_path") + if isinstance(value, str) and value: + seen.append(value) +print("\n".join(dict.fromkeys(seen))) +' 2>/dev/null || true) +fi + +if [ -z "$CHANGED" ]; then + CHANGED=$(git diff --name-only HEAD 2>/dev/null | head -100) +fi + +# Paths arrive absolute from the payload; every tool below expects them +# relative to the repository root. +if [ -n "$CHANGED" ]; then + CHANGED=$(printf '%s\n' "$CHANGED" | sed "s|^$(pwd)/||" | grep -v '^$' || true) +fi + +if [ -z "$CHANGED" ]; then + exit 0 +fi + +# No repo-configured linters were detected during bootstrap. +# Re-run bootstrap_agentic_repo.py --update after adding linter configs. + +if [ -f .agents/hooks/check_file_size_budget.py ]; then + python3 .agents/hooks/check_file_size_budget.py --changed || FAIL=1 +fi + +if [ -f .agents/hooks/check_complexity.py ]; then + # Scoped to the files this edit wrote (see CHANGED above): the gate reports + # on the deliverable, not on every file the branch has ever touched. + # shellcheck disable=SC2086 + python3 .agents/hooks/check_complexity.py --changed $CHANGED || true +fi + +# Skill suggestions are deliberately NOT invoked here. Injecting "you should +# have loaded skill X" after the edit has already landed is advice that arrives +# too late to act on. suggest-skills.py is wired as a PRE-edit hook instead, +# and only for the harnesses that need it — Claude resolves skills from the +# `paths:` front matter in its own rules. + +exit $FAIL diff --git a/.agents/hooks/pretool_guard.py b/.agents/hooks/pretool_guard.py new file mode 100755 index 0000000..3579e3a --- /dev/null +++ b/.agents/hooks/pretool_guard.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +""" +pretool_guard.py — pre-flight gate for agent tool calls. + +This runs BEFORE a tool executes and inspects the *pending* call: the content +about to be written, or the command about to run. That is the whole point. The +pre-commit scanners look at the index, which by definition does not yet contain +what the agent is about to do, so they can audit but they cannot prevent. + +Three platform contracts, all verified against the platforms themselves rather +than assumed. They differ in both the input shape and how a block is signalled: + + Claude Code in : {"tool_name": "Write", "tool_input": {...}, "cwd": ...} + out: {"hookSpecificOutput": {"hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": "..."}} + NOTE: exit code 1 is explicitly NON-blocking in Claude Code — + it logs the error and proceeds. Only exit 2, or an explicit + deny decision, actually stops the call. + + Antigravity in : {"toolCall": {"name": "write_to_file", + "args": {"TargetFile": ..., "CodeContent": ...}}} + out: {"decision": "deny", "reason": "..."} + + Copilot in : {"toolName": ..., "toolArgs": {...}} + out: {"permissionDecision": "deny", + "permissionDecisionReason": "..."} + +Fail open, never closed: a payload this script cannot understand must not block +the agent's work. A guard that halts every tool call the moment a platform +changes its schema gets switched off, and then nothing is guarded at all. +""" + +import json +import os +import re +import subprocess +import sys + +HOOKS_DIR = os.path.abspath(os.path.dirname(__file__)) +if HOOKS_DIR not in sys.path: + sys.path.insert(0, HOOKS_DIR) +from secret_scanner import SecretScanner # noqa: E402 +from path_scanner import PathScanner # noqa: E402 + +FORBIDDEN_BRANCHES = ("main", "master", "staging") + +# Commands that produce history. The branch guard applies to these only: +# blocking every Bash call on a protected branch would stop `git status` and +# the agent could not even diagnose its way out. +COMMIT_COMMAND = re.compile(r"\bgit\s+(?:commit|push|merge|rebase)\b") + +# Tool names that write file content, per platform. +WRITE_TOOLS = { + "write", "edit", "multiedit", "notebookedit", # Claude Code + "write_to_file", "replace_file_content", # Antigravity + "multi_replace_file_content", "create_file", "edit_file", + "str_replace_editor", "applypatch", "apply_patch", +} +SHELL_TOOLS = {"bash", "run_command", "shell", "terminal", "runcommand"} + +# Keys that may carry a path or content, across every platform's arg naming. +PATH_KEYS = ("file_path", "filePath", "path", "TargetFile", "target_file", + "notebook_path", "filename") +CONTENT_KEYS = ("content", "CodeContent", "code_content", "new_string", + "new_str", "text", "contents", "ReplacementContent", + "new_source", "patch", "Patch") +COMMAND_KEYS = ("command", "CommandLine", "cmd", "commandLine", "script") + + +def collect_strings(value, out, depth=0): + """Antigravity's replace_file_content nests replacement chunks, so the + content is not always at a predictable top-level key.""" + if depth > 6: + return + if isinstance(value, str): + out.append(value) + elif isinstance(value, dict): + for v in value.values(): + collect_strings(v, out, depth + 1) + elif isinstance(value, list): + for v in value: + collect_strings(v, out, depth + 1) + + +def parse_request(payload): + """Normalise the three payload shapes into (platform, tool, path, blobs).""" + if "toolCall" in payload: # Antigravity + platform = "antigravity" + call = payload.get("toolCall") or {} + tool = (call.get("name") or "").lower() + args = call.get("args") or {} + elif "toolName" in payload: # Copilot + platform = "copilot" + tool = (payload.get("toolName") or "").lower() + args = payload.get("toolArgs") or {} + elif "tool_name" in payload: # Claude Code + platform = "claude" + tool = (payload.get("tool_name") or "").lower() + args = payload.get("tool_input") or {} + else: + return None, None, None, [] + + if isinstance(args, str): + try: + args = json.loads(args) + except json.JSONDecodeError: + args = {"command": args} + if not isinstance(args, dict): + args = {} + + path = next((args[k] for k in PATH_KEYS + if isinstance(args.get(k), str)), None) + + blobs = [] + if tool in SHELL_TOOLS: + for key in COMMAND_KEYS: + if isinstance(args.get(key), str): + blobs.append(args[key]) + else: + for key in CONTENT_KEYS: + if isinstance(args.get(key), str): + blobs.append(args[key]) + if not blobs and tool in WRITE_TOOLS: + # Fall back to a deep scan rather than silently checking nothing. + collect_strings(args, blobs) + return platform, tool, path, blobs + + +def current_branch(): + result = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, text=True) + return result.stdout.strip() if result.returncode == 0 else "" + + +# Generated/compiled files with an owning CLI. Hand-editing them is a silent +# trap (Talisman honours only the FIRST entry for a filename, the compiled +# exclusion targets are overwritten by the compiler, the manifest is the +# regeneration ledger), so edits are denied and redirected at the tool. +MANAGED_FILE_OWNERS = { + ".talismanrc": "python3 .agents/hooks/talisman_guard.py allow|refresh", + ".ignore": "edit .aiignore, then python3 .agents/hooks/compile_aiignore.py", + ".github/copilot-content-exclusion.yml": + "edit .aiignore, then python3 .agents/hooks/compile_aiignore.py", + ".agents/rules-manifest.json": "re-run the agentic bootstrap", + "GEMINI.md": "edit AGENTS.md, then bash scripts/sync_agentic_configs.sh", +} + + +def managed_file_violation(tool, path): + if tool in SHELL_TOOLS or not path: + return None + normalised = path.replace("\\", "/").removeprefix("./") + for managed, owner in MANAGED_FILE_OWNERS.items(): + if normalised == managed or normalised.endswith("/" + managed): + return (f"{managed} is generated state and must never be " + f"hand-edited (edits silently no-op or are overwritten). " + f"Use the owning command instead: {owner}") + return None + + +def find_violation(tool, path, blobs): + """Return a human-readable reason to block, or None to allow.""" + managed = managed_file_violation(tool, path) + if managed: + return managed + for blob in blobs: + for line in blob.splitlines(): + if SecretScanner.scan_line(line): + return ("This change contains what looks like a credential " + "(private key, API token, or passphrase). Secrets " + "must never be written into the repository.") + + # Absolute home paths are only meaningful in file content; a shell command + # legitimately references absolute paths all the time. + if tool not in SHELL_TOOLS: + for blob in blobs: + for line in blob.splitlines(): + has_viol, bad_paths = PathScanner.scan_line(line) + if has_viol: + return ("This change hardcodes an absolute local path " + "({!r}). Use a path relative to the repository " + "root, or resolve it at runtime." + .format(bad_paths[0])) + + if tool in SHELL_TOOLS: + for blob in blobs: + if COMMIT_COMMAND.search(blob): + branch = current_branch() + if branch in FORBIDDEN_BRANCHES: + return ("Refusing to run a history-changing git command on " + "'{}'. Create a feature branch named after the " + "active ticket first.".format(branch)) + return None + + +def deny(platform, reason): + if platform == "antigravity": + print(json.dumps({"decision": "deny", "reason": reason})) + elif platform == "copilot": + print(json.dumps({"permissionDecision": "deny", + "permissionDecisionReason": reason})) + else: + print(json.dumps({"hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + }})) + sys.exit(0) + + +def main(): + try: + raw = sys.stdin.read() + except (OSError, ValueError): + return + if not raw.strip(): + return + try: + payload = json.loads(raw) + except json.JSONDecodeError: + return + if not isinstance(payload, dict): + return + + platform, tool, path, blobs = parse_request(payload) + if platform is None: + return + + reason = find_violation(tool, path, blobs) + if reason: + deny(platform, reason) + # Silence means "no opinion" on every platform: the normal permission flow + # continues. Never print an allow decision — that would override the user's + # own settings. + + +if __name__ == "__main__": + main() diff --git a/.agents/hooks/rule_advisories.py b/.agents/hooks/rule_advisories.py new file mode 100755 index 0000000..2c37b81 --- /dev/null +++ b/.agents/hooks/rule_advisories.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +""" +rule_advisories.py + +The checks on the rule set that are HEURISTICS rather than decisions. + +These are kept apart from the blocking checks in `compile_rule_frontmatter.py` +deliberately. Each one answers a question that is not decidable from the text — +"does this description earn its context load", "is this enforcement claim +true", "is this sentence a rule or orientation" — so each will occasionally be +wrong. They report; they do not block. When one has run for a cycle and its +false-positive rate is known, promoting it to the blocking registry is a +one-line change, because the seam is here. + +A heuristic that blocks from day one turns its first false positive into an +argument about the checker instead of an argument about the guidance. + +WHY THESE FOUR. An audit of one repository's 24 generated rules found eight +defects that no mechanism would have caught: a build rule shipping the literal +placeholder "(no test runner detected)"; an OWASP rule prescribing REST auth +and SQL parameterisation for a project with no network surface; a C++ rule +requiring `ctest` where no test was ever registered; a PR rule pointing at a +template path that did not exist; and a UI rule mandating a skill that resolves +nowhere on the machine. Each advisory below is the generalisation of one of +those findings. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +NORMATIVE = re.compile( + r"\b(MUST NOT|MUST|SHALL NOT|SHALL|NEVER|ALWAYS|STRICTLY FORBIDDEN" + r"|is forbidden|are forbidden|is blocked|is prohibited)\b") + +RULE_POINTER = re.compile(r"(\.agents/rules/|rules?\s+\d{2}\b|rule\s+`?\d{2})", + re.IGNORECASE) + +ENFORCEMENT = re.compile( + r"\b(blocks?|blocked|refuses?|prevents?|rejects?|forbids?)\b", re.IGNORECASE) + +HOOK_REFERENCE = re.compile(r"[\w/]*hooks?/[\w-]+\.(?:py|sh)") + +#: Placeholders a generated rule must never ship with. A rule whose build +#: command is "(no test runner detected)" costs a turn and teaches doubt about +#: every other rule in the set. +#: +#: Deliberately NOT matching a bare ``. Measured against a +#: real 23-rule set, that shape produced six findings and every one was a +#: false positive: `docker compose build `, `git merge origin/`, +#: `gh pr edit --body-file ` — ordinary command-line +#: metavariables, which are exactly how a rule SHOULD write a command the +#: reader must fill in. An advisory that fires on correct documentation is +#: noise, and noise is what stops advisories being read at all. +PLACEHOLDER = re.compile( + r"(\bTBD\b|\bTODO\b|\bFIXME\b|\(no [a-z ]+ detected\)" + r"|\bXXX\b|\bPLACEHOLDER\b|<(?:project|repo|repository|insert|your)[ _-])") + +#: Skills are named as slash commands and are usually written in backticks, so +#: only a preceding word character or slash (a URL or path) disqualifies a +#: match. A neighbouring slash or angle bracket means it was a filesystem path +#: (`/dev/shm`, `/proc//cwd`), and a trailing colon means it was a +#: namespace (`/opsx:*`) rather than a skill name. +#: +#: `*` and `.` are excluded on both sides because a glob is not a skill: +#: `**/conandata.yml` and `**/conanfile.py` were reported as the skills +#: `/conandata` and `/conanfile` against a real rule set, and a rule that +#: declares its own file scope is the single most common place a `/` appears. +SLASH_SKILL = re.compile(r"(?*.])/([a-z][a-z0-9-]{2,})\b(?![/:.])") + +#: Words that would make a description a restatement of its own file name +#: rather than a statement of when the rule matters. +_FILLER = {"rules", "rule", "standards", "guidelines", "conventions", "and", + "for", "the", "of", "in", "this", "repository"} + + +def skill_search_dirs() -> list[Path]: + """Every directory a skill name could resolve in on this machine. + + Returns empty when none exist, and the caller then skips the check: a + developer without a skills directory must not be told every skill the rules + name is missing. + """ + home = Path(os.path.expanduser("~")) + dirs = [Path(".claude/skills"), Path(".skills"), home / ".claude/skills"] + dirs.extend(sorted((home / ".claude/plugins/cache").glob("*/*/skills"))) + return [d for d in dirs if d.is_dir()] + + +def check_description_quality(name: str, description: str) -> list[str]: + """A description that only restates the name cannot earn a model's load.""" + if not description: + return [] + words = {w.strip(".,`").lower() for w in description.split()} + meaningful = words - _FILLER - set(name.split("-")) + if len(meaningful) < 4: + return ["description adds little beyond the rule name: {!r}".format( + description)] + return [] + + +def check_no_placeholders(body: str) -> list[str]: + """A generated rule that shipped its own template hole is worse than absent.""" + findings = [] + fenced = False + for number, line in enumerate(body.splitlines(), start=1): + if line.lstrip().startswith("```"): + fenced = not fenced + continue + if fenced: + continue + match = PLACEHOLDER.search(line) + if match: + findings.append( + "line {}: unfilled placeholder {!r} — the bootstrap could not " + "detect this value, so the rule states nothing actionable" + .format(number, match.group(0))) + return findings + + +def check_skills_resolve(body: str, search_dirs: list[Path]) -> list[str]: + """A rule that mandates an unavailable skill costs a turn and teaches doubt.""" + if not search_dirs: + return [] + known = set() + for parent in search_dirs: + try: + known |= {d.name for d in parent.iterdir() if d.is_dir()} + except OSError: + continue + findings = [] + for match in SLASH_SKILL.finditer(body): + skill = match.group(1) + if skill not in known: + findings.append( + "names skill `/{}`, which resolves in no skill search " + "path".format(skill)) + return sorted(set(findings)) + + +def check_enforcement_claims(body: str) -> list[str]: + """Surface every claim that a mechanism blocks something, for audit. + + Whether such a claim is true is not decidable here — a guard can be + correctly configured and still not intercept the actor the sentence is + addressed to. So this LISTS the claims rather than judging them, and asks + for each to name the actor and context it actually covers. + """ + findings = [] + for number, line in enumerate(body.splitlines(), start=1): + if not HOOK_REFERENCE.search(line) or not ENFORCEMENT.search(line): + continue + findings.append( + "line {}: enforcement claim — confirm it still holds and that it " + "names the actor it covers: {}".format(number, line.strip()[:90])) + return findings + + +def _normative_lines(text: str) -> list[tuple[int, str]]: + out = [] + fenced = False + for number, line in enumerate(text.splitlines(), start=1): + if line.lstrip().startswith("```"): + fenced = not fenced + continue + if fenced or not NORMATIVE.search(line) or RULE_POINTER.search(line): + continue + out.append((number, line.strip())) + return out + + +def check_layering(paths: list[Path]) -> list[tuple[Path, str]]: + """Orientation files point at rules; they do not restate them. + + A duplicated rule drifts from its original, and an agent that finds the + stale copy follows it. Normative language outside the rule set is therefore + reported — unless the sentence names the rule it is pointing at. + """ + findings = [] + for path in paths: + if not path.is_file(): + continue + try: + text = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + for number, line in _normative_lines(text): + findings.append(( + path, + "line {}: normative language outside the rule set — point at " + "the rule instead: {}".format(number, line[:90]))) + return findings + + +def advisories_for_rule(name: str, description: str, body: str, + search_dirs: list[Path]) -> list[str]: + """Every heuristic finding for one rule file, in report order.""" + findings = list(check_description_quality(name, description)) + findings.extend(check_no_placeholders(body)) + findings.extend(check_skills_resolve(body, search_dirs)) + findings.extend(check_enforcement_claims(body)) + return findings diff --git a/.agents/hooks/run_adversarial_audit.py b/.agents/hooks/run_adversarial_audit.py new file mode 100755 index 0000000..c66027f --- /dev/null +++ b/.agents/hooks/run_adversarial_audit.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +""" +run_adversarial_audit.py +Automated Adversarial Security, Quality Gate & Intent Scope Audit Script. + +Scans git diff and commit history for: +1. Hardcoded absolute paths (e.g. user home directories) +2. Private keys, API tokens, credentials +3. Python error swallowing +4. Raw hex colour literals in themeable sources — NOT just QML: React, Python + UIs and stylesheets hardcode `#RRGGBB` just as readily +5. Interface changes that leave the API documentation behind +6. Edits to trees this repository vendors but does not own + +Every folder list this script uses is discovered at bootstrap and rendered in +from ONE source (`hooks/partials/_repo_layout.py.j2`). Earlier revisions carried +private hardcoded copies of an interface directory, a vendor directory and a +default-branch list — literals lifted from one firmware repository, meaningless +in every other repository the bootstrap touched. +""" + +import os +from pathlib import Path +import re +import subprocess +import sys + +HOOKS_DIR = os.path.abspath(os.path.dirname(__file__)) +if HOOKS_DIR not in sys.path: + sys.path.insert(0, HOOKS_DIR) +from secret_scanner import SecretScanner # noqa: E402 +from path_scanner import PathScanner # noqa: E402 + +# --- this repository's layout, discovered at bootstrap (generated) --------- +# ONE source for the folder lists. Several hooks used to carry their own +# hardcoded copies of a vendor-directory list and of a default-branch list, +# which was both duplication and wrong: a firmware repository vendors into its +# own SDK directory and protects a release branch under a project-specific +# name, and no hardcoded copy could know either. +# +# Every value below comes from the investigation the bootstrap ran against THIS +# repository — not from a default list. Re-run the bootstrap with `--update` +# after the layout changes. + +#: Trees this repository consumes but does not own. Never reformat or edit. +VENDORED_PREFIXES: tuple[str, ...] = () + +#: Branches nobody may commit to directly. Discovered from the remote's own +#: protection settings via `gh`, falling back to the detected base branch. +PROTECTED_BRANCHES: tuple[str, ...] = ( + 'master', +) + +#: The PR base for this repository, recorded once so no script has to guess. +BASE_BRANCH: str = "master" + +#: Directories holding a published interface whose docs must move with it. +INTERFACE_PREFIXES: tuple[str, ...] = () + +#: Where this repository documents that interface. +API_DOC_PATHS: tuple[str, ...] = () + +#: Sources where a raw #RRGGBB literal belongs in a theme token instead. +#: Not QML-only: React, Python UIs and stylesheets hardcode colours too. +THEMEABLE_SUFFIXES: tuple[str, ...] = ( + '.qml', + '.py', + '.css', + '.scss', + '.less', +) + +#: The theme/token definitions themselves — the one place literals belong. +THEME_DEFINITION_FILES: tuple[str, ...] = ( + 'Theme.qml', + 'theme.ts', + 'tokens.css', +) + + +def is_vendored(path: str) -> bool: + return any(path.startswith(prefix) for prefix in VENDORED_PREFIXES) + + +def is_themeable_source(path: str) -> bool: + return (path.endswith(THEMEABLE_SUFFIXES) + and not any(name in path for name in THEME_DEFINITION_FILES)) + + +HEX_COLOR_PATTERN = re.compile(r"#(?:[0-9a-fA-F]{3}){1,2}\b") + + +def _git_lines(*args): + result = subprocess.run(["git", *args], capture_output=True, text=True) + if result.returncode != 0: + return [] + return [f.strip() for f in result.stdout.splitlines() if f.strip()] + + +def get_git_diff_files(): + """Everything this branch changes relative to its base, plus uncommitted + work. Diffing only the working tree made this audit a no-op at pre-push + time on a clean tree — committed changes were never audited at all.""" + files = set(_git_lines("diff", "--name-only", "HEAD")) + files |= set(_git_lines("diff", "--cached", "--name-only")) + merge_base = _git_lines("merge-base", "HEAD", f"origin/{BASE_BRANCH}") + if merge_base: + files |= set(_git_lines("diff", "--name-only", f"{merge_base[0]}..HEAD")) + return sorted(files) + + +# Files where an absolute user path may legitimately appear as generated +# content rather than as something a human committed. Deliberately NOT +# `.md` wholesale: exempting every markdown file let absolute paths through +# in documentation, which the security-and-paths rule explicitly forbids, and +# documentation is exactly where a developer's home directory tends to be +# pasted from a terminal transcript. +_PATH_EXEMPT_PREFIXES = (".agents/rules/",) + + +def _path_exempt(filepath: str) -> bool: + return filepath.startswith(_PATH_EXEMPT_PREFIXES) + + +def _check_line_patterns(filepath, idx, line, content, errors): + if PathScanner.scan_line(line)[0] and not _path_exempt(filepath): + errors.append(f"❌ [ABSOLUTE PATH] {filepath}:{idx}: {line.strip()}") + + if SecretScanner.scan_line(line): + errors.append(f"❌ [SECRET DETECTED] {filepath}:{idx}") + + if filepath.endswith(".py"): + c1 = "except Exception as e:" in line + c2 = "except Exception:" in line + if c1 or c2: + w_start = max(0, idx - 1) + w_end = min(len(content), idx + 5) + window = "".join(content[w_start:w_end]) + has_exit = "sys.exit" in window or "file=sys.stderr" in window + if not has_exit: + errors.append( + f"⚠️ [PYTHON ERROR SWALLOWING] {filepath}:{idx}: " + "Exception caught without sys.exit or stderr output." + ) + + if is_themeable_source(filepath) and HEX_COLOR_PATTERN.search(line): + errors.append( + f"⚠️ [HARDCODED HEX COLOR] {filepath}:{idx}: " + f"{line.strip()} (use this project's theme tokens instead)" + ) + + +def _check_architectural_limits(files, errors): + # Only apply the API-doc coupling where those interface trees exist in + # THIS repository; a foreign repo's layout is not evidence here. + live_interfaces = [p for p in INTERFACE_PREFIXES if Path(p).is_dir()] + interface_files = [f for f in files + if any(f.startswith(p) for p in live_interfaces)] + api_doc_files = [f for f in files + if f in API_DOC_PATHS or "openapi" in f.lower()] + if interface_files and API_DOC_PATHS and not api_doc_files: + errors.append( + f"❌ [API DOC DESYNC] Interface files modified " + f"({len(interface_files)} files) but {', '.join(API_DOC_PATHS)} " + "was not updated!" + ) + + vendor_files = [f for f in files if is_vendored(f)] + if vendor_files: + errors.append( + f"❌ [VENDOR SDK MODIFIED] {len(vendor_files)} vendor files " + f"modified (e.g. {vendor_files[0]}). Vendor code must remain untouched!" + ) + + +def _audit_single_file(filepath, errors): + path = Path(filepath) + if not path.exists() or path.is_dir(): + return + + # Guards whose own source must contain the patterns they detect, plus the + # fire-proofing harness whose fixtures ARE violations by construction. + # Without this the audit failed every bootstrap PR on the bootstrap's own + # output, even on a clean tree. Exact filenames, never directory prefixes: + # a blanket `.agents/hooks/` skip would be a place to hide a real secret. + SELF_EXEMPT_NAMES = frozenset({ + "block-absolute-paths.py", "block-secrets.py", "path_scanner.py", + "secret_scanner.py", "pretool_guard.py", "check_security_downgrades.py", + "run_adversarial_audit.py", "verify_hooks_fire.py", + }) + if path.name in SELF_EXEMPT_NAMES: + return + + try: + with open(path, "r", encoding="utf-8", errors="ignore") as f: + content = f.readlines() + + for idx, line in enumerate(content, 1): + _check_line_patterns(filepath, idx, line, content, errors) + except OSError: + return + + +def audit_diff(): + sec_hook = Path(__file__).parent / "check_security_downgrades.py" + if sec_hook.exists(): + res = subprocess.run([sys.executable, str(sec_hook)]) + if res.returncode != 0: + return 1 + + files = get_git_diff_files() + if not files: + print("==> Adversarial Audit: No modified files detected in git diff.") + return 0 + + errors = [] + print("==> Running Adversarial Security, Quality & Intent Audit on " + f"{len(files)} modified files...") + + for filepath in files: + _audit_single_file(filepath, errors) + + _check_architectural_limits(files, errors) + + # Scope judgement lives in check_multi_intent_scope.py — one hook, one + # question. Delegating rather than re-deriving it here keeps the two from + # disagreeing about what "too wide" means. + scope_hook = Path(__file__).parent / "check_multi_intent_scope.py" + if scope_hook.exists(): + res = subprocess.run([sys.executable, str(scope_hook)]) + if res.returncode != 0: + return 1 + + if errors: + print("\n" + "=" * 74) + print("🚨 ADVERSARIAL AUDIT FINDINGS & INTENT EVALUATION:") + print("=" * 74) + for err in errors: + print(err) + print("=" * 74 + "\n") + crit_keys = ["ABSOLUTE PATH", "SECRET DETECTED", "API DOC DESYNC", + "VENDOR SDK MODIFIED"] + critical_errors = [e for e in errors if any(ck in e for ck in crit_keys)] + if critical_errors: + print("❌ Critical security findings must be resolved.") + return 1 + + print("✅ Adversarial Security, Quality & Intent Audit Passed Cleanly!") + return 0 + + +if __name__ == "__main__": + sys.exit(audit_diff()) diff --git a/.agents/hooks/secret_scanner.py b/.agents/hooks/secret_scanner.py new file mode 100755 index 0000000..1084347 --- /dev/null +++ b/.agents/hooks/secret_scanner.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +import os +import re +import subprocess +import sys + +# --- shared detection patterns (generated from ONE source) ----------------- +# Every guard that scans content imports these: the pre-flight guard +# (`pretool_guard.py`), the two pre-commit scanners (via `secret_scanner.py` +# and `path_scanner.py`), and the pre-PR gate (`run_adversarial_audit.py`). +# +# WHY THIS PARTIAL EXISTS. Downstream, four guards each carried their own copy +# under a comment claiming the copies were "generated from one source" and so +# could not drift apart. There was no source, and they HAD drifted: the pre-PR +# gate held four of the eight secret shapes — missing PKCS#8 private keys and +# the Slack, OpenAI and AWS credentials — and its GitHub regex matched only +# `ghp_` rather than the whole `gh[pousr]_` family that `gh auth` mints. A +# security policy is knowledge, so it is written once, here, and included. +# +# Do NOT re-declare a credential shape at a call site. Add it here. +# +# One caution for whoever edits this next: a pattern written as a literal token +# rather than a prefix plus a character class would make this catalogue match +# its own source, and the scanners would block the commit that adds it. None of +# the shapes below do — `[` sits outside every character class — and the +# two files this partial is inlined into (`secret_scanner.py`, +# `path_scanner.py`) are already listed in `_self_exempt.py.j2`, so a future +# literal example cannot turn the catalogue into the thing it blocks. + +SECRET_PATTERNS = [ + # PKCS#1 / OpenSSH / PGP and the PKCS#8 forms that `openssl genpkey` and + # `ssh-keygen -m PKCS8` emit by default — the latter were previously missed. + re.compile(r"-----BEGIN (?:RSA|OPENSSH|DSA|EC|PGP) PRIVATE KEY-----"), + re.compile(r"-----BEGIN(?: ENCRYPTED)? PRIVATE KEY-----"), + re.compile(r"AIzaSy[A-Za-z0-9_-]{33}"), # Google API key + # The whole `gh` token family, not just `ghp_`: `gh auth` mints `gho_`, + # `ghu_`, `ghs_` and `ghr_` too, and each is equally a live credential. + re.compile(r"gh[pousr]_[A-Za-z0-9]{36,}"), # GitHub tokens + # GitHub fine-grained PAT + re.compile(r"github_pat_[0-9a-zA-Z]{22}_[0-9a-zA-Z]{59}"), + re.compile(r"glpat-[A-Za-z0-9_-]{20}"), # GitLab PAT + re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), # Slack + re.compile(r"sk-[A-Za-z0-9]{32,}"), # OpenAI-style + re.compile(r"\bAKIA[0-9A-Z]{16}\b"), # AWS access key id + # AWS secret keys carry no prefix, so they are only recognisable next to + # the assignment that names them. + re.compile(r"(?i)aws_secret_access_key\s*=\s*['\"]?([0-9a-zA-Z/+]{40})['\"]?"), +] + +HOME_PATH_PATTERN = re.compile(r"/home/[a-zA-Z0-9_-]+/") +USERS_PATH_PATTERN = re.compile(r"/Users/[a-zA-Z0-9_-]+/") +ABSOLUTE_PATH_PATTERNS = [HOME_PATH_PATTERN, USERS_PATH_PATTERN] + + +# --- self-exemption, by exact identity (generated from one source) --------- +# A guard's own source is the one place in the repository where the pattern it +# forbids MUST appear as a literal: a regex that matches a leaked private key +# necessarily contains a description of a leaked private key. Scanning the +# guards made a freshly bootstrapped repository unable to commit itself. +# +# The exemption is deliberately by EXACT PATH, not by directory prefix. +# Exempting `.agents/hooks/` wholesale would create a blind spot big enough to +# hide a real credential in — the precise failure these guards exist to +# prevent. Every other file under `.agents/`, including new hooks, is scanned +# at full strength, and the fire-proofing fixtures are assembled at runtime +# (see `verify_hooks_fire.py`) rather than exempted. +SELF_EXEMPT_FILES = frozenset({ + ".agents/hooks/secret_scanner.py", + ".agents/hooks/path_scanner.py", + ".agents/hooks/block-secrets.py", + ".agents/hooks/block-absolute-paths.py", + ".agents/hooks/check_security_downgrades.py", + ".agents/hooks/pretool_guard.py", + ".agents/hooks/run_adversarial_audit.py", + ".agents/hooks/verify_hooks_fire.py", + "scripts/check_security_downgrades.py", + "scripts/run_adversarial_audit.py", +}) + + +def _normalise_diff_path(path: str) -> str: + """Strip the `a/` or `b/` prefix git puts on diff headers.""" + path = path.strip() + if path.startswith(("a/", "b/")): + path = path[2:] + return path + + +def _is_self_exempt(path: str) -> bool: + return _normalise_diff_path(path) in SELF_EXEMPT_FILES + + +class SecretScanner: + @classmethod + def scan_line(cls, line: str) -> list[str]: + findings = [] + for pattern in SECRET_PATTERNS: + match = pattern.search(line) + if match: + findings.append(match.group(0)) + return findings + + @classmethod + def scan_staged(cls) -> bool: + diff_cmd = subprocess.run( + ["git", "diff", "--cached", "-U0"], + capture_output=True, + text=True, + check=False, + ) + if diff_cmd.returncode != 0: + return False + + found_secrets = False + skip_file = False + for line in diff_cmd.stdout.splitlines(): + if line.startswith("+++ "): + target = line[4:].strip() + skip_file = _is_self_exempt(target) + continue + if skip_file: + continue + if line.startswith("+"): + findings = cls.scan_line(line[1:]) + if findings: + print( + "❌ SecretScanner blocked commit: detected hardcoded " + f"secret token: {findings[0][:8]}...", + file=sys.stderr, + ) + found_secrets = True + + return found_secrets diff --git a/.agents/hooks/suggest-skills.py b/.agents/hooks/suggest-skills.py new file mode 100755 index 0000000..1ad8cf9 --- /dev/null +++ b/.agents/hooks/suggest-skills.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +suggest-skills.py + +Advisory hook: maps the files about to be edited to the UltiCortex skills that +cover them, so the relevant expertise is loaded BEFORE the code is written. + +Runs PRE-edit (`--pre-edit`), reading the target path out of the harness's tool +payload on stdin. Reporting after the edit told the agent it should have loaded +a skill it can no longer act on — advice arriving one turn too late. + +Not wired for Claude: Claude resolves skills from the `paths:` front matter in +.claude/rules and loads them itself, so a hook there duplicates the harness. + +Never fails a build — steering belongs in the rules; this is the reminder. +""" + +import fnmatch +import json +import os +import subprocess +import sys + +# skill -> (globs, one-line reason) +SKILL_TRIGGERS = {} + +_ROOT = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True +).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + + +# Keys the supported harnesses use for the path a tool is about to touch. +_PATH_KEYS = ("file_path", "filePath", "path", "target_file", "TargetFile", + "absolute_path", "notebook_path") + + +def _paths_in(payload): + """Every plausible target path in a tool-call payload, at any depth. + + Each harness names the field differently and nests it differently; walking + the structure is cheaper than maintaining a matrix of shapes that silently + yields nothing the day one of them renames a key. + """ + found = [] + if isinstance(payload, dict): + for key, value in payload.items(): + if key in _PATH_KEYS and isinstance(value, str) and value.strip(): + found.append(value.strip()) + else: + found.extend(_paths_in(value)) + elif isinstance(payload, list): + for item in payload: + found.extend(_paths_in(item)) + return found + + +def pre_edit_targets(): + """The file this tool call is about to write, read from stdin.""" + try: + raw = sys.stdin.read() if not sys.stdin.isatty() else "" + except (OSError, ValueError): + return [] + if not raw.strip(): + return [] + try: + payload = json.loads(raw) + except ValueError: + return [] + root = os.getcwd() + os.sep + return [p[len(root):] if p.startswith(root) else p + for p in _paths_in(payload)] + + +def changed_files(): + for args in (["diff", "--cached", "--name-only"], ["diff", "--name-only", "HEAD"]): + res = subprocess.run(["git", *args], capture_output=True, text=True) + files = [f for f in res.stdout.splitlines() if f.strip()] + if files: + return files + return [] + + +def matches(path: str, glob: str) -> bool: + """fnmatch has no notion of `**`, and its `*` already spans `/`. A pattern + anchored with `**/` must therefore also be tried without that prefix, or it + would never match a file sitting at the repository root.""" + if fnmatch.fnmatch(path, glob): + return True + if glob.startswith("**/") and fnmatch.fnmatch(path, glob[3:]): + return True + return False + + +def main(): + pre_edit = "--pre-edit" in sys.argv + files = pre_edit_targets() if pre_edit else [] + if not files and not pre_edit: + files = changed_files() + if not files: + return + hits = {} + for skill, (globs, reason) in SKILL_TRIGGERS.items(): + for path in files: + if any(matches(path, g) for g in globs): + hits.setdefault(skill, [reason, []])[1].append(path) + if not hits: + return + print("Relevant UltiCortex skills for the files you are about to change —" + " load these BEFORE writing the code:" if pre_edit else + "Relevant UltiCortex skills for the files you are changing:") + for skill, (reason, paths) in sorted(hits.items()): + sample = ", ".join(paths[:3]) + (" ..." if len(paths) > 3 else "") + print(" - {}: {}".format(skill, reason)) + print(" triggered by: {}".format(sample)) + print(" gh skill install Ultimaker/UltiCortex {}".format(skill)) + + +if __name__ == "__main__": + main() diff --git a/.agents/hooks/talisman_guard.py b/.agents/hooks/talisman_guard.py new file mode 100755 index 0000000..1038254 --- /dev/null +++ b/.agents/hooks/talisman_guard.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +""" +talisman_guard.py — the only sanctioned way to change .talismanrc. + +The whitelist is generated state. Hand-editing is a documented trap: Talisman +honours the FIRST entry for a filename, so a hand-appended second entry is a +silent no-op. Verbs: + + merge %O %A %B git union merge driver (semantic union of entries; always + exits 0 — a driver that reports conflict reintroduces the + textual-conflict problem it exists to remove). Genuinely + divergent checksums for the same filename are deferred to + `restamp` and reported. + allow ... add checksum entries for the given files (new findings). + refresh re-checksum already-whitelisted files whose content + drifted. Deliberately narrower than allow: it never adds + an entry, so a genuinely new finding still blocks and + still needs a human. + restamp resolve entries deferred by a merge. + +Every digest comes from `talisman --checksum` itself, one call per path, after +the path has been staged — Talisman's checksum is computed over what git knows +about, so an untracked file yields nothing and a call covering several patterns +yields one collective digest matching none of them. `allow`, `refresh` and +`restamp` therefore stage what they checksum and fail loudly without the +binary; a locally computed hash would look like an exemption while exempting +nothing. `merge` computes no digest and works on any clone. + +Register the driver per-clone (done by scripts/sync_agentic_configs.sh and +verify_and_create_pr.sh): + git config merge.talismanrc.driver \ + "python3 .agents/hooks/talisman_guard.py merge %O %A %B" +GitHub's server-side "mergeable" badge cannot see a repo-local driver: a +CONFLICTING badge on a .talismanrc-only conflict is cosmetic. +""" + +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +_ROOT = subprocess.run(["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + +RC = Path(".talismanrc") +DEFERRED = Path(".agents/.talismanrc-deferred") + + +def parse(text: str): + """(entries dict, other_sections text). Entries are an unordered map. + + An entry's value is its checksum, or "" when it has none. A checksum-less + entry is real state — a gitlink carries `ignore_detectors` instead, and a + file whose digest could not be computed is listed bare on purpose — so it + must survive a rewrite. Dropping those entries silently re-enabled every + detector they configured. + """ + entries = {} + extras = {} + other = [] + skip = False + current = None + for line in text.splitlines(): + if re.match(r"^fileignoreconfig:", line): + skip = True + current = None + continue + if re.match(r"^[A-Za-z_][\w]*:", line): + skip = False + current = None + if not skip: + other.append(line) + continue + name_match = re.match(r"^-\s*filename:\s*(\S+)", line) + if name_match: + current = name_match.group(1) + entries[current] = "" + continue + if current is None: + continue + sum_match = re.match(r"^\s+checksum:\s*(\S+)", line) + if sum_match: + entries[current] = sum_match.group(1) + else: + extras.setdefault(current, []).append(line) + return entries, "\n".join(other).strip("\n"), extras + + +def emit(entries: dict, other: str, extras: dict = None) -> str: + lines = ["fileignoreconfig:"] + for name in sorted(entries): + lines.append(f"- filename: {name}") + if entries[name]: + lines.append(f" checksum: {entries[name]}") + lines.extend((extras or {}).get(name, [])) + if other: + lines.append(other) + return "\n".join(lines) + "\n" + + +ENTRY_PAIR_RE = re.compile( + r"-\s*filename:\s*(\S+)\s*\n\s*checksum:\s*([a-fA-F0-9]{64})") + + +class TalismanMissing(RuntimeError): + """No talisman binary, so no digest can be produced. Never guess one.""" + + +def checksums(paths): + """Talisman's own digest for each path: {path: digest}. + + Talisman's digest is not a hash of the file bytes that anything else can + reproduce, and it is computed from what git knows about — an untracked file + yields nothing at all. Three hand-rolled approximations in this toolchain + each wrote digests Talisman rejected, so every exemption they produced was + inert. Hence: stage first, ask the binary, one call per path (a call with + several patterns collapses into one collective digest), and refuse to + invent anything when the binary is absent. + """ + paths = list(paths) + if not paths: + return {} + if shutil.which("talisman") is None: + raise TalismanMissing( + "talisman is not installed, so no checksum can be computed. A " + "guessed digest looks like an exemption while exempting nothing. " + "Install talisman (https://github.com/thoughtworks/talisman) and " + "re-run this command.") + if subprocess.run(["git", "add", "--", *paths], + capture_output=True).returncode != 0: + for path in paths: + subprocess.run(["git", "add", "--", path], capture_output=True) + digests = {} + for path in paths: + res = subprocess.run(["talisman", "--checksum", path], + capture_output=True, text=True) + pairs = ENTRY_PAIR_RE.findall(res.stdout) + for name, digest in pairs: + if name.strip("'\"") == path: + digests[path] = digest + break + else: + if len(pairs) == 1: + digests[path] = pairs[0][1] + return digests + + +def merge(base_p, ours_p, theirs_p) -> int: + read = lambda p: Path(p).read_text(errors="ignore") if Path(p).exists() else "" + base_e, _, _ = parse(read(base_p)) + ours_e, ours_other, ours_extras = parse(read(ours_p)) + theirs_e, theirs_other, theirs_extras = parse(read(theirs_p)) + merged = dict(ours_e) + extras = dict(theirs_extras) + extras.update(ours_extras) + deferred = [] + for name, digest in theirs_e.items(): + if name not in merged: + merged[name] = digest + elif merged[name] != digest: + if base_e.get(name) == merged[name]: + merged[name] = digest # only theirs moved + elif base_e.get(name) == digest: + pass # only ours moved + else: + deferred.append(name) # genuinely divergent: defer, keep ours + Path(ours_p).write_text( + emit(merged, ours_other or theirs_other, extras)) + if deferred: + DEFERRED.parent.mkdir(exist_ok=True) + with open(DEFERRED, "a") as f: + f.write("\n".join(deferred) + "\n") + print(f"talisman_guard: {len(deferred)} divergent checksum(s) deferred " + "- run: python3 .agents/hooks/talisman_guard.py restamp", + file=sys.stderr) + return 0 # never report conflict; that is the whole point + + +def _report_unresolved(names) -> None: + print("talisman_guard: talisman produced no checksum for " + f"{len(names)} path(s); they are NOT exempt: " + ", ".join(sorted(names)), + file=sys.stderr) + + +def allow(paths) -> int: + entries, other, extras = parse( + RC.read_text(errors="ignore") if RC.exists() else "") + for path in paths: + if not Path(path).exists(): + print(f"talisman_guard: no such file: {path}", file=sys.stderr) + return 1 + digests = checksums(paths) + unresolved = [p for p in paths if p not in digests] + if unresolved: + # Writing the entry anyway would leave a whitelist line that whitelists + # nothing, which reads as protection at review time. + _report_unresolved(unresolved) + return 1 + for path in paths: + entries[path] = digests[path] + RC.write_text(emit(entries, other, extras)) + print(f"talisman_guard: whitelisted {len(paths)} file(s).") + return 0 + + +def refresh() -> int: + entries, other, extras = parse( + RC.read_text(errors="ignore") if RC.exists() else "") + # Only entries that name an existing file: a glob entry has no single file + # to re-checksum, and `refresh` never adds an entry that is not already here. + present = [name for name in entries if Path(name).is_file()] + digests = checksums(present) + changed = 0 + for name in present: + digest = digests.get(name) + if digest and digest != entries[name]: + entries[name] = digest + changed += 1 + RC.write_text(emit(entries, other, extras)) + unresolved = [name for name in present if name not in digests] + if unresolved: + _report_unresolved(unresolved) + print(f"talisman_guard: refreshed {changed} drifted entr(y/ies). " + "New findings still need `allow` and a human.") + return 1 if unresolved else 0 + + +def restamp() -> int: + if not DEFERRED.exists(): + print("talisman_guard: nothing deferred.") + return 0 + names = [n for n in DEFERRED.read_text().splitlines() if n.strip()] + entries, other, extras = parse( + RC.read_text(errors="ignore") if RC.exists() else "") + present = [n for n in names if Path(n).is_file()] + digests = checksums(present) + for name, digest in digests.items(): + entries[name] = digest + RC.write_text(emit(entries, other, extras)) + unresolved = [n for n in present if n not in digests] + if unresolved: + _report_unresolved(unresolved) + return 1 + DEFERRED.unlink() + print(f"talisman_guard: restamped {len(names)} deferred entr(y/ies).") + return 0 + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + return 2 + verb = sys.argv[1] + # `merge` never computes a digest, so it keeps working without talisman — + # that is what makes it safe as a git merge driver on any clone. + if verb == "merge" and len(sys.argv) >= 5: + return merge(sys.argv[2], sys.argv[3], sys.argv[4]) + try: + if verb == "allow": + return allow(sys.argv[2:]) + if verb == "refresh": + return refresh() + if verb == "restamp": + return restamp() + except TalismanMissing as exc: + print(f"talisman_guard: {exc}", file=sys.stderr) + return 1 + print(__doc__) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/hooks/verify_hooks_fire.py b/.agents/hooks/verify_hooks_fire.py new file mode 100755 index 0000000..9712995 --- /dev/null +++ b/.agents/hooks/verify_hooks_fire.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +""" +verify_hooks_fire.py — fire-proofing. + +"A hook you have not watched fire is unproven." Every rollout PR claimed +"N/N hooks passed 100% cleanly" while shipping hooks that could not execute. +This script actually triggers each blocking guard once, with a deliberately +violating synthetic fixture, and records the observed result in +.agents/bootstrap-profile.json under `hook_verification`. The PR's V&V table +is generated from that record — a pass-count nobody ran becomes unwritable. + +Exit non-zero if any exercised guard failed to block its violation. +""" + +import json +import os +import subprocess +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path + +_ROOT = subprocess.run(["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + +HOOKS = Path(".agents/hooks") +PROFILE = Path(".agents/bootstrap-profile.json") + +# Fixtures are ASSEMBLED AT RUNTIME, never written out as literals. +# +# This file's whole job is to feed each guard something the guard must reject. +# Spelling those violations out in the source made the guards reject THIS FILE: +# every bootstrap commit then failed its own secret scanner, path scanner and +# adversarial audit, and the tree could not be committed at all. Exempting the +# file from the guards would have been a weakening — a real leaked key in a +# skipped file is exactly what these guards exist to catch. Composing the +# fixtures from fragments keeps the guards at full strength and leaves nothing +# here for them to match. +_PEM_EDGE = "-" * 5 +_KEY_WORD = "KEY" +_PRIVATE = "PRIVATE" +FAKE_KEY = (f"{_PEM_EDGE}BEGIN RSA {_PRIVATE} {_KEY_WORD}{_PEM_EDGE}\n" + f"MIIEvFAKEFAKEFAKE\n{_PEM_EDGE}END RSA {_PRIVATE} {_KEY_WORD}{_PEM_EDGE}\n") +_HOME_ROOT = "/" + "home" +FAKE_PATH = f"config = '{_HOME_ROOT}/exampleuser/secret/config.yaml'\n" + + +def _run(cmd, **kwargs): + return subprocess.run(cmd, capture_output=True, text=True, **kwargs) + + +def _record(results, hook, fired, blocked, detail=""): + results[hook] = { + "fired": fired, "blocked": blocked, "detail": detail, + "at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + } + status = "BLOCKED" if blocked else ("ran" if fired else "NOT RUN") + print(f" [{status:>7}] {hook}{' — ' + detail if detail else ''}") + + +def _fixture(content, suffix=".py"): + handle = tempfile.NamedTemporaryFile( + "w", suffix=suffix, dir=".", prefix=".fireproof-", delete=False) + handle.write(content) + handle.close() + return handle.name + + +def exercise_file_guard(results, script, content, hook_id): + """These guards scan the STAGED diff, so the violating fixture is staged + for the duration of the probe and unstaged afterwards.""" + path = HOOKS / script + if not path.exists(): + return + fixture = _fixture(content) + try: + _run(["git", "add", "--force", fixture]) + res = _run([sys.executable, str(path)]) + _record(results, hook_id, True, res.returncode != 0, + "staged violation rejected" if res.returncode != 0 + else "staged violation ACCEPTED") + finally: + _run(["git", "reset", "-q", "--", fixture]) + os.unlink(fixture) + + +def exercise_pretool_guard(results): + path = HOOKS / "pretool_guard.py" + if not path.exists(): + return + payload = json.dumps({ + "hook_event_name": "PreToolUse", "tool_name": "Write", + "tool_input": {"file_path": "src/x.py", "content": FAKE_KEY}, + }) + res = _run([sys.executable, str(path)], input=payload) + blocked = res.returncode == 2 or '"deny"' in res.stdout + _record(results, "pretool-guard", True, blocked, + "secret payload denied" if blocked else "secret payload ALLOWED") + + +def exercise_kill_guard(results): + path = HOOKS / "block_name_matched_kill.py" + if not path.exists(): + return + payload = json.dumps({ + "hook_event_name": "PreToolUse", "tool_name": "Bash", + "tool_input": {"command": "pkill -f my_service"}, + }) + res = _run([sys.executable, str(path)], input=payload) + blocked = res.returncode == 2 or '"deny"' in res.stdout + _record(results, "block-name-matched-kill", True, blocked, + "pkill denied" if blocked else "pkill ALLOWED") + + +def exercise_run_only(results, script, hook_id, *args): + """Advisory/analysis hooks: prove they execute without crashing.""" + path = HOOKS / script + if not path.exists(): + return + res = _run([sys.executable, str(path), *args]) + _record(results, hook_id, True, None, + f"exit {res.returncode} (advisory; executed, not violated)") + + +def exercise_e2e_evidence_gate(results): + """The E2E-evidence gate (cloud/frontend repos only). Prove BOTH directions + on synthetic fixtures fed through the hook's testing seam: a user-facing + diff with an evidence-less body must BLOCK, and the same diff with evidence + present must PASS. A gate proven only to reject is half-proven.""" + path = HOOKS / "check_e2e_evidence.py" + if not path.exists(): + return + changed = _fixture("frontend/src/App.tsx\nsrc/api/JobHandler.py\n", suffix=".txt") + evidence_less = _fixture( + "## Why\nFix the print button.\n\n## Verification & Validation\n" + "Unit tests pass (42/42). No browser run.\n", suffix=".md") + with_evidence = _fixture( + "## Why\nFix the print button.\n\n## Verification & Validation\n" + "Logged in and printed a job end to end:\n\n" + "![print button enabled](https://github.com/user-attachments/assets/" + "abc123.png)\n", suffix=".md") + try: + rej = _run([sys.executable, str(path), + "--changed-paths-file", changed, + "--body-file", evidence_less]) + _record(results, "check-e2e-evidence", True, rej.returncode != 0, + "evidence-less cloud diff rejected" if rej.returncode != 0 + else "evidence-less cloud diff ACCEPTED") + acc = _run([sys.executable, str(path), + "--changed-paths-file", changed, + "--body-file", with_evidence]) + # This direction must NOT block; a wrongful block is a false positive. + _record(results, "check-e2e-evidence-passthrough", True, + False if acc.returncode != 0 else None, + "evidence-present cloud diff accepted (exit 0)" + if acc.returncode == 0 + else "FALSE POSITIVE: evidence present but gate BLOCKED") + finally: + for f in (changed, evidence_less, with_evidence): + os.unlink(f) + + +def _outcomes(record: dict) -> dict: + """The parts of a verification record that carry information. + + Excludes `at`, which changes on every run by construction. + """ + return { + hook: {k: v for k, v in entry.items() if k != "at"} + for hook, entry in (record or {}).items() + } + + +def main(): + if not HOOKS.is_dir(): + print("No .agents/hooks directory; nothing to verify.") + return 0 + print("==> Fire-proofing: triggering each guard against a violating fixture") + results = {} + exercise_file_guard(results, "block-secrets.py", FAKE_KEY, "block-secrets") + exercise_file_guard(results, "block-absolute-paths.py", FAKE_PATH, + "block-absolute-paths") + exercise_pretool_guard(results) + exercise_kill_guard(results) + exercise_run_only(results, "compile_rule_frontmatter.py", + "check-rule-frontmatter", "--check") + exercise_run_only(results, "audit_quad_agent_parity.py", + "audit-quad-agent-parity", ".") + exercise_run_only(results, "check_upstream_alignment.py", + "check-upstream-alignment") + exercise_e2e_evidence_gate(results) + + head = _run(["git", "rev-parse", "--short", "HEAD"]).stdout.strip() + for entry in results.values(): + entry["sha"] = head + + if PROFILE.exists(): + try: + profile = json.loads(PROFILE.read_text()) + except ValueError: + profile = {} + # Idempotent: `verify_and_create_pr.sh` runs this on every verification, + # and rewriting the record with fresh timestamps each time dirtied the + # working tree and churned the committed profile for no new information. + # Only the OUTCOMES matter for comparison; `at` is volatile by design. + if _outcomes(profile.get("hook_verification", {})) == _outcomes(results): + print(f" Hook verification unchanged for {head}; " + f"{PROFILE} left as-is") + else: + profile["hook_verification"] = results + PROFILE.write_text(json.dumps(profile, indent=2) + "\n") + print(f" Recorded {len(results)} result(s) in {PROFILE}") + + failures = [h for h, r in results.items() if r["blocked"] is False] + if failures: + print(f"==> FIRE-PROOFING FAILED: guards that did not block: {failures}") + return 1 + print("==> Fire-proofing complete.") + return 0 + + +def render_vv_table() -> str: + """The V&V table for the PR body, generated from the recorded runs.""" + try: + results = json.loads(PROFILE.read_text()).get("hook_verification", {}) + except (OSError, ValueError): + results = {} + if not results: + return "No hook_verification record — run verify_hooks_fire.py first.\n" + lines = ["| Hook | Fired | Blocked violation | When |", + "| --- | --- | --- | --- |"] + for hook in sorted(results): + r = results[hook] + blocked = {True: "yes", False: "NO", None: "n/a (advisory)"}[r["blocked"]] + lines.append(f"| {hook} | {'yes' if r['fired'] else 'no'} | {blocked} " + f"| {r['at']} ({r.get('sha', '?')}) |") + return "\n".join(lines) + "\n" + + +if __name__ == "__main__": + if "--vv-table" in sys.argv: + print(render_vv_table(), end="") + sys.exit(0) + sys.exit(main()) diff --git a/.aiignore b/.aiignore new file mode 100644 index 0000000..d841b11 --- /dev/null +++ b/.aiignore @@ -0,0 +1,81 @@ +# .aiignore — files that must not be fed to an AI model +# +# This file is the SINGLE SOURCE OF TRUTH for AI context exclusion in this +# repository. Edit it here, then run: +# +# python3 .agents/hooks/compile_aiignore.py +# +# which translates these patterns into the mechanism each agent platform +# actually honours (.ignore for ripgrep-based search, deny rules for Claude +# Code and OpenCode, and a paste-ready list for GitHub Copilot's org-level +# content exclusion). No platform reads .aiignore directly. +# +# Syntax is .gitignore syntax. + +# Secrets and credentials +# Never let key material or tokens enter a model context window. +*.pem +*.key +*.p12 +*.pfx +*.jks +*.keystore +id_rsa +id_ecdsa +id_ed25519 +*.gpg +*.asc +.env +.env.* +.env-* +.env_* +!.env.example +!.env-example +*.tfvars +.netrc +.npmrc +credentials.json +service-account*.json +**/secrets/** + +# Third-party code and SDKs +# Not ours to change; reading it wastes context and invites edits to vendored trees. +vendor/ +third_party/ +node_modules/ + +# Build output and caches +# Generated artefacts carry no design intent. +build/ +dist/ +out/ +target/ +.venv/ +venv/ +__pycache__/ +*.o +*.a +*.so +*.dylib +*.dll +*.exe +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ + +# Large or binary payloads +# High token cost, near-zero reasoning value. +*.stl +*.3mf +*.gcode +*.ufp +*.obj +*.step +*.stp + +# Personal and customer data +# Privacy: log dumps and captures routinely contain PII. +**/*.log +**/logdump*/** +**/customer-data/** +**/pii/** diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ef2b97c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.talismanrc merge=talismanrc diff --git a/.github/copilot-content-exclusion.yml b/.github/copilot-content-exclusion.yml new file mode 100644 index 0000000..a356fcc --- /dev/null +++ b/.github/copilot-content-exclusion.yml @@ -0,0 +1,61 @@ +# GENERATED FROM .aiignore by .agents/hooks/compile_aiignore.py +# +# GitHub Copilot content exclusion is applied SERVER SIDE and cannot be +# configured by a file in the repository. Paste the block below into: +# GitHub -> Organization (or Repository) Settings -> Copilot -> +# Content exclusion +# +# Until that is done, Copilot will still read these paths. + +"*": + - "/**/*.pem" + - "/**/*.key" + - "/**/*.p12" + - "/**/*.pfx" + - "/**/*.jks" + - "/**/*.keystore" + - "/**/id_rsa" + - "/**/id_ecdsa" + - "/**/id_ed25519" + - "/**/*.gpg" + - "/**/*.asc" + - "/**/.env" + - "/**/.env.*" + - "/**/.env-*" + - "/**/.env_*" + - "/**/*.tfvars" + - "/**/.netrc" + - "/**/.npmrc" + - "/**/credentials.json" + - "/**/service-account*.json" + - "/**/secrets/**" + - "/**/vendor/**" + - "/**/third_party/**" + - "/**/node_modules/**" + - "/**/build/**" + - "/**/dist/**" + - "/**/out/**" + - "/**/target/**" + - "/**/.venv/**" + - "/**/venv/**" + - "/**/__pycache__/**" + - "/**/*.o" + - "/**/*.a" + - "/**/*.so" + - "/**/*.dylib" + - "/**/*.dll" + - "/**/*.exe" + - "/**/.mypy_cache/**" + - "/**/.pytest_cache/**" + - "/**/.ruff_cache/**" + - "/**/*.stl" + - "/**/*.3mf" + - "/**/*.gcode" + - "/**/*.ufp" + - "/**/*.obj" + - "/**/*.step" + - "/**/*.stp" + - "/**/*.log" + - "/**/logdump*/**" + - "/**/customer-data/**" + - "/**/pii/**" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01ad97c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# Added by the agentic bootstrap: byproducts of the hooks it installs. +__pycache__/ +*.py[cod] +.agents/**/*.bootstrap-tmp diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..a337e8d --- /dev/null +++ b/.ignore @@ -0,0 +1,83 @@ +# GENERATED FROM .aiignore by .agents/hooks/compile_aiignore.py +# Do not edit this file directly — edit .aiignore and re-run the compiler. +# .aiignore — files that must not be fed to an AI model +# +# This file is the SINGLE SOURCE OF TRUTH for AI context exclusion in this +# repository. Edit it here, then run: +# +# python3 .agents/hooks/compile_aiignore.py +# +# which translates these patterns into the mechanism each agent platform +# actually honours (.ignore for ripgrep-based search, deny rules for Claude +# Code and OpenCode, and a paste-ready list for GitHub Copilot's org-level +# content exclusion). No platform reads .aiignore directly. +# +# Syntax is .gitignore syntax. + +# Secrets and credentials +# Never let key material or tokens enter a model context window. +*.pem +*.key +*.p12 +*.pfx +*.jks +*.keystore +id_rsa +id_ecdsa +id_ed25519 +*.gpg +*.asc +.env +.env.* +.env-* +.env_* +!.env.example +!.env-example +*.tfvars +.netrc +.npmrc +credentials.json +service-account*.json +**/secrets/** + +# Third-party code and SDKs +# Not ours to change; reading it wastes context and invites edits to vendored trees. +vendor/ +third_party/ +node_modules/ + +# Build output and caches +# Generated artefacts carry no design intent. +build/ +dist/ +out/ +target/ +.venv/ +venv/ +__pycache__/ +*.o +*.a +*.so +*.dylib +*.dll +*.exe +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ + +# Large or binary payloads +# High token cost, near-zero reasoning value. +*.stl +*.3mf +*.gcode +*.ufp +*.obj +*.step +*.stp + +# Personal and customer data +# Privacy: log dumps and captures routinely contain PII. +**/*.log +**/logdump*/** +**/customer-data/** +**/pii/** diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c3d10c9 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,133 @@ +# @generated by the agentic bootstrap. Regenerated on every run; the absence +# of this header is how the writer recognises a hand-written file and refuses +# to overwrite it. Edit the bootstrap, not this file. +default_stages: [pre-commit] +# Keep this exclude narrow: it exempts matching paths from EVERY hook. A broad +# entry (e.g. `software/.*`) has excluded >99% of a tree from every gate. +# Generation fails if this pattern matches more than half of tracked files. +exclude: "^(vendor/.*|third_party/.*|node_modules/.*|build/.*)" +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-merge-conflict + args: [--assume-in-merge] + - id: check-yaml + args: [--unsafe] + - id: check-json + # Never tightened silently: when the repository already set a ceiling, that + # ceiling is carried forward. A bootstrap that quietly dropped a repo from + # 40000 KB to 2000 KB starts blocking assets that were fine yesterday, and + # the diff gives no clue why. + - id: check-added-large-files + args: ['--maxkb=2000'] + # MUTATING HOOKS ARE SCOPED TO THE BOOTSTRAP'S OWN OUTPUT. + # These two rewrite the files they inspect. Unscoped, a single + # `pre-commit run --all-files` — which agents run as verification — + # reformatted 190 files in one repository and 189 in another, and that + # churn landed in pull requests about something else entirely. The + # bootstrap introduces these hooks; the repository never asked for them; + # so they police what the bootstrap writes and nothing else. Repo-wide + # formatting stays the repository's own decision, in its own config. + - id: end-of-file-fixer + files: '^(\.agents/|\.claude/|\.opencode/|\.github/hooks/|\.github/copilot-|AGENTS\.md$|GEMINI\.md$|DESIGN\.md$|\.aiignore$|\.ignore$|\.gitattributes$|\.talismanrc$|opencode\.json$|\.pre-commit-config\.yaml$|scripts/(sync_agentic_configs|verify_and_create_pr)\.sh$)' + - id: trailing-whitespace + files: '^(\.agents/|\.claude/|\.opencode/|\.github/hooks/|\.github/copilot-|AGENTS\.md$|GEMINI\.md$|DESIGN\.md$|\.aiignore$|\.ignore$|\.gitattributes$|\.talismanrc$|opencode\.json$|\.pre-commit-config\.yaml$|scripts/(sync_agentic_configs|verify_and_create_pr)\.sh$)' + + - repo: https://github.com/thoughtworks/talisman + rev: v1.32.0 + hooks: + - id: talisman-commit + entry: talisman --githook pre-commit + + - repo: local + hooks: + + - id: block-absolute-paths + name: Block absolute local path references + entry: python3 .agents/hooks/block-absolute-paths.py + language: system + types_or: [text] + + - id: block-secrets + name: Detect hardcoded API keys and credentials + entry: python3 .agents/hooks/block-secrets.py + language: system + types_or: [text] + + - id: git-branch-guard + name: Refuse commits on protected branches + entry: python3 .agents/hooks/git-branch-guard.py + language: system + types_or: [text] + + - id: check-aiignore-sync + name: Verify AI exclusion targets match .aiignore + entry: python3 .agents/hooks/compile_aiignore.py --check + language: system + pass_filenames: false + types_or: [text] + + - id: check-rule-frontmatter + name: Enforce rule numbering bands, frontmatter coherence and managed-space ownership + entry: python3 .agents/hooks/compile_rule_frontmatter.py --check + language: system + pass_filenames: false + types_or: [text] + + - id: check-copilot-rule-index + name: Verify the Copilot rule index lists every rule in .agents/rules + entry: python3 .agents/hooks/copilot_index.py --check + language: system + pass_filenames: false + types_or: [text] + + - id: check-file-size-budget + name: Enforce file size budget and grandfathering ratchet + entry: python3 .agents/hooks/check_file_size_budget.py --staged + language: system + pass_filenames: false + types_or: [text] + + - id: audit-quad-agent-parity + name: Verify 100% parity across Quad-Agent configuration targets + entry: python3 .agents/hooks/audit_quad_agent_parity.py + language: system + pass_filenames: false + types_or: [text] + + - id: check-security-downgrades + name: Prevent disabling pre-commit hooks or Talisman credentials scanning + entry: python3 .agents/hooks/check_security_downgrades.py + language: system + pass_filenames: false + types_or: [text] + + - id: check-upstream-alignment + name: Verify alignment with upstream release branches + entry: python3 .agents/hooks/check_upstream_alignment.py + language: system + pass_filenames: false + types_or: [text] + + - id: check-multi-intent-scope + name: Detect multi-intent PRs mixing features with refactoring + entry: python3 .agents/hooks/check_multi_intent_scope.py + language: system + types_or: [text] + + - id: check-atomic-bisect-history + name: Validate atomic bisect-safe commit history + entry: python3 .agents/hooks/check_atomic_bisect_history.py + language: system + pass_filenames: false + stages: [pre-push] + types_or: [text] + + - id: run-adversarial-audit + name: Run automated red-team adversarial security audit + entry: python3 .agents/hooks/run_adversarial_audit.py + language: system + pass_filenames: false + stages: [pre-push] + types_or: [text] diff --git a/.talismanrc b/.talismanrc new file mode 100644 index 0000000..ae1c663 --- /dev/null +++ b/.talismanrc @@ -0,0 +1,99 @@ +fileignoreconfig: +- filename: .agents/agents/adversarial_pr_reviewer/agent.md + checksum: acd2df30e0a0e39a9ecf917e32217a5e2d2d05ba860950ed94e44358ab6eb518 +- filename: .agents/agents/architecture_investigator/agent.md + checksum: 5d6d2f2da7d60aa1596b687df4c509880f52a28e501506127cc21bb0389232de +- filename: .agents/agents/ecosystem_contract_investigator/agent.md + checksum: 152c89ad1643017ff95524464f002afd93dd503ff126628ffc383623b5f9ac81 +- filename: .agents/aiignore-generated.json + checksum: 3355eb90016a2f2cd3d5ab65e64827c25a5be4af2884f55eaef308c36e05d2a4 +- filename: .agents/bootstrap-observations.md + checksum: f7028b4a83024b311eca5172e2c02aec544e5a4afd0e5f6d49ad8dfbd4494eb2 +- filename: .agents/bootstrap-profile.json + checksum: 9aab899250a983102ffe573618f9703fab56ebef6d939947aff53d3eea0cb196 +- filename: .agents/dispatch-invariants.md + checksum: be5d7c02eeda9dc187860a375b1d108a50bcc6e1d17b34bfdcd355a9e2703e57 +- filename: .agents/file-size-baseline.json + checksum: 1262a5c740dffc4af592482f599e6838955e4b6f73e8b235654ed58dd58f274c +- filename: .agents/hooks.json + checksum: 88b454e19f878219342d6a9f325b181a56386dd2ba074deec186a0b20ba2a2a0 +- filename: .agents/hooks/audit_quad_agent_parity.py + checksum: 6198a24d2de75dfb18ba7fef0097488a846474a1ea6d37c591d838f736b4b046 +- filename: .agents/hooks/block-absolute-paths.py + checksum: a7b42da7c259eea9f47e40d61b79bdef670e3d8f428099f2112cac95d8a147f4 +- filename: .agents/hooks/block-secrets.py + checksum: 5b31c274b41db944380fe5c5717441eb9a15a6d6aa99ab1cf138518f82883286 +- filename: .agents/hooks/block_name_matched_kill.py + checksum: 9dc2e672d8cb3c3e41c619556c769f677e2abcf0785d4bcdf74fb986b6d0b572 +- filename: .agents/hooks/check_atomic_bisect_history.py + checksum: be25ce8583127226a58c9fa573e71aaa330a84b97dcfb36557931731a7a49f9e +- filename: .agents/hooks/check_file_size_budget.py + checksum: 12458e166a02055bd697567dabca58c6305f72c551610872bfe5533e949a09b3 +- filename: .agents/hooks/check_multi_intent_scope.py + checksum: f87e5a3d5ed7c6fafbc825caa037180e66afa7142363b1bb4c84b4dec4c10162 +- filename: .agents/hooks/check_security_downgrades.py + checksum: daeef25d032cdfea566ddb1f2f0c1c09dc86af473d9bac1491b36932f1844c77 +- filename: .agents/hooks/check_upstream_alignment.py + checksum: 521a0507e6da2c3c9119cb0fe407cd782a890117b1a843630a40eb996ac52a99 +- filename: .agents/hooks/compile_aiignore.py + checksum: 9b790aff74c1f2644c1cffe250fb5decca184a73e1cd8730d2a4751d04a0f64a +- filename: .agents/hooks/compile_rule_frontmatter.py + checksum: ccfb29e389b298c9064c77521a2e1e8b905e7de098e41c7269db3c7c0c39cb76 +- filename: .agents/hooks/copilot_index.py + checksum: ed69332425cf95373c9b3ebe5603a2944b891e6a7742067e514dc9fbe40c256f +- filename: .agents/hooks/file_size_baseline.py + checksum: 219d3ca8004cb4d1322852973bc0f18ab85e9409f77d8841a3f771feb8aa74c0 +- filename: .agents/hooks/file_size_scope.py + checksum: 51f79f03e5077d0b2bf5b19a0788129e7d9b558340582513bdbb43f08aedc998 +- filename: .agents/hooks/git-branch-guard.py + checksum: db87d73a95cd6a864764c9e0a142a0dec37404b87cfa47adf1e4c07f454c675e +- filename: .agents/hooks/path_scanner.py + checksum: 264a159d977383e6d129bb83bbdb045474115fd211f725f81e12d61ec3705d96 +- filename: .agents/hooks/pretool_guard.py + checksum: 0d64f5206bddf8fd78140e46b34f065bd0e5560111be7d98fdd9cf59f1ad975d +- filename: .agents/hooks/rule_advisories.py + checksum: 748f82c307f8ad6f6a06a64c5407b00e2eb0ac2e7f5d53d7e69999dd0e933c15 +- filename: .agents/hooks/run_adversarial_audit.py + checksum: 5971a1af52de9101c7488c0250483670ecc9c792680eb748ff16cd02751ce8f8 +- filename: .agents/hooks/secret_scanner.py + checksum: 6c26b54f5ca1d24bffcbc42667c2f4740b5f5f939a8fee86df027c17d5b4b400 +- filename: .agents/hooks/suggest-skills.py + checksum: 41ccdced1d46bf818089ceb511407701ca5875f75890d8fcb9f2d5c85fc8f704 +- filename: .agents/hooks/talisman_guard.py + checksum: 2ec479c135086905f1bf6ece7d356bcb28b6cc1d6ad7415220b45811dd10b883 +- filename: .agents/hooks/verify_hooks_fire.py + checksum: 1ff249022f14b5371f27481f37f9a1e228ae8cf57e308dfa20f10496582014e4 +- filename: .agents/rules-manifest.json + checksum: fdf1108221a575fc10f20cc70c15914f9dbe856aafdb60f9031afdedb3c37e92 +- filename: .agents/rules/01-jira-commit-standards.md + checksum: 96a8bedeac9f2a67141e0d22c94f8a34f857d0077318ff41bb03e7484ccc926c +- filename: .agents/rules/02-security-and-paths.md + checksum: f3279797a883e9db0ec9af65fa609a4a24c33a99e201e4c71c42804e78b6bd21 +- filename: .agents/rules/04-build-test-and-deployment-rules.md + checksum: cda189d3f2951d648f7d769a37479cf59c30bf7245475c860ba491b8b3577ac3 +- filename: .agents/rules/05-ultimaker-skill-discovery-rules.md + checksum: 218b3d471b0d53add352631cc9f475e86fb0e123328ae89c60186fa2dff62da3 +- filename: .agents/rules/06-pull-request-lifecycle-rules.md + checksum: bec438eff8d14344d41349a10cd8f26e9633a93a1480da1f4df693045c068b68 +- filename: .agents/rules/07-owasp-security-rules.md + checksum: c164300ab7cf3035f75b8df01ff1bda5978a5e7d30a19b538c854aedff525f57 +- filename: .agents/rules/08-scoped-changes-and-minimal-diffs.md + checksum: 7dced8031a5e52a013b8ef093700f230b29cf82f4cf12fdf6ef712e2b17ece71 +- filename: .agents/rules/09-atomic-bisect-safe-commits.md + checksum: d3eb665180d55754b6a03309df5be9a2e1263c02fdaa8316d12cc8a2ec60596c +- filename: .agents/rules/10-file-size-and-decomposition-rules.md + checksum: 0ef95feda4c3ae3227cf7069ff1057dd6f5f926ca993f9fda88905b9439fa48e +- filename: .agents/rules/12-ai-context-exclusion-rules.md + checksum: b98ec88f0fc55f64d91fc0ea2ceaf8e60fb44049ffc4b7ca987dcfccbd20199c +- filename: .agents/rules/13-dependency-management-rules.md + checksum: 0160a92aa5e698984e9bb2d7c8938c93cc5112685ad1d988b0af2a5848270f91 +- filename: .agents/rules/34-library-consumer-contract-rules.md + checksum: f6bf3153eb90633d8652175fbff0009ef7dcc45033b77fe3d9f7b7f8d6cfe36c +- filename: .agents/rules/40-skill-discovery-index-rules.md + checksum: ef1ae03e2f9cd50f029e0a7fdc7d54a97e6e75faf7aa6961a434ab331c878367 +- filename: .github/copilot-instructions.md + checksum: 4bc9449dd968058a24eeafa7f30fd712e5958a244f0aae676caee1f79f2a30d9 +version: "1.0" +threshold: medium +custom_patterns: +- (password|passwd|secret|api_key|apikey|access_token|auth_token|private_key)\s*[:=]\s*["']?[A-Za-z0-9/+_-]{12,} From 23dd39f4aefce9a2782976daf14afab89d1fdebd Mon Sep 17 00:00:00 2001 From: jellespijker Date: Sat, 15 Aug 2026 14:04:06 +0200 Subject: [PATCH 2/3] [UC-3697] Add the rule set and the four-harness agent configuration Add the rules and link them into every agent tool. - .agents/rules/ holds the rules in numbered load-tier bands. - .agents/rules/15-shipped-runner-and-consumer-rules.md is hand written. It records that this repository ships the run_*.sh scripts as its product and has no test suite of its own. - .claude/, .opencode/, .github/ and AGENTS.md link the same rule files. - .agents/agents/ holds the review and investigation subagents. --- .../agents/adversarial_pr_reviewer/agent.md | 71 +++++ .../agents/architecture_investigator/agent.md | 163 ++++++++++ .../ecosystem_contract_investigator/agent.md | 142 +++++++++ .agents/bootstrap-observations.md | 126 ++++++++ .agents/bootstrap-profile.json | 283 ++++++++++++++++++ .agents/dispatch-invariants.md | 33 ++ .agents/rules-manifest.json | 17 ++ .agents/rules/01-jira-commit-standards.md | 15 + .agents/rules/02-security-and-paths.md | 21 ++ .../04-build-test-and-deployment-rules.md | 25 ++ .../05-ultimaker-skill-discovery-rules.md | 47 +++ .../rules/06-pull-request-lifecycle-rules.md | 36 +++ .agents/rules/07-owasp-security-rules.md | 17 ++ .../08-scoped-changes-and-minimal-diffs.md | 41 +++ .../rules/09-atomic-bisect-safe-commits.md | 17 ++ .../10-file-size-and-decomposition-rules.md | 91 ++++++ .../rules/12-ai-context-exclusion-rules.md | 44 +++ .../rules/13-dependency-management-rules.md | 120 ++++++++ .../15-shipped-runner-and-consumer-rules.md | 55 ++++ .../34-library-consumer-contract-rules.md | 56 ++++ .../rules/40-skill-discovery-index-rules.md | 42 +++ .claude/agents/adversarial_pr_reviewer.md | 1 + .claude/agents/architecture_investigator.md | 1 + .../agents/ecosystem_contract_investigator.md | 1 + .claude/rules/01-jira-commit-standards.md | 1 + .claude/rules/02-security-and-paths.md | 1 + .../04-build-test-and-deployment-rules.md | 1 + .../05-ultimaker-skill-discovery-rules.md | 1 + .../rules/06-pull-request-lifecycle-rules.md | 1 + .claude/rules/07-owasp-security-rules.md | 1 + .../08-scoped-changes-and-minimal-diffs.md | 1 + .../rules/09-atomic-bisect-safe-commits.md | 1 + .../10-file-size-and-decomposition-rules.md | 1 + .../rules/12-ai-context-exclusion-rules.md | 1 + .../rules/13-dependency-management-rules.md | 1 + .../15-shipped-runner-and-consumer-rules.md | 1 + .../34-library-consumer-contract-rules.md | 1 + .../rules/40-skill-discovery-index-rules.md | 1 + .claude/settings.json | 89 ++++++ .github/agents/adversarial_pr_reviewer.md | 1 + .github/agents/architecture_investigator.md | 1 + .../agents/ecosystem_contract_investigator.md | 1 + .github/copilot-instructions.md | 39 +++ .github/hooks/copilot-hooks.json | 33 ++ .opencode/agents/adversarial_pr_reviewer.md | 1 + .opencode/agents/architecture_investigator.md | 1 + .../agents/ecosystem_contract_investigator.md | 1 + .opencode/rules/01-jira-commit-standards.md | 1 + .opencode/rules/02-security-and-paths.md | 1 + .../04-build-test-and-deployment-rules.md | 1 + .../05-ultimaker-skill-discovery-rules.md | 1 + .../rules/06-pull-request-lifecycle-rules.md | 1 + .opencode/rules/07-owasp-security-rules.md | 1 + .../08-scoped-changes-and-minimal-diffs.md | 1 + .../rules/09-atomic-bisect-safe-commits.md | 1 + .../10-file-size-and-decomposition-rules.md | 1 + .../rules/12-ai-context-exclusion-rules.md | 1 + .../rules/13-dependency-management-rules.md | 1 + .../15-shipped-runner-and-consumer-rules.md | 1 + .../34-library-consumer-contract-rules.md | 1 + .../rules/40-skill-discovery-index-rules.md | 1 + .opencode/rules/agents.md | 1 + AGENTS.md | 103 +++++++ CLAUDE.md | 1 + GEMINI.md | 106 +++++++ opencode.json | 172 +++++++++++ 66 files changed, 2043 insertions(+) create mode 100644 .agents/agents/adversarial_pr_reviewer/agent.md create mode 100644 .agents/agents/architecture_investigator/agent.md create mode 100644 .agents/agents/ecosystem_contract_investigator/agent.md create mode 100644 .agents/bootstrap-observations.md create mode 100644 .agents/bootstrap-profile.json create mode 100644 .agents/dispatch-invariants.md create mode 100644 .agents/rules-manifest.json create mode 100644 .agents/rules/01-jira-commit-standards.md create mode 100644 .agents/rules/02-security-and-paths.md create mode 100644 .agents/rules/04-build-test-and-deployment-rules.md create mode 100644 .agents/rules/05-ultimaker-skill-discovery-rules.md create mode 100644 .agents/rules/06-pull-request-lifecycle-rules.md create mode 100644 .agents/rules/07-owasp-security-rules.md create mode 100644 .agents/rules/08-scoped-changes-and-minimal-diffs.md create mode 100644 .agents/rules/09-atomic-bisect-safe-commits.md create mode 100644 .agents/rules/10-file-size-and-decomposition-rules.md create mode 100644 .agents/rules/12-ai-context-exclusion-rules.md create mode 100644 .agents/rules/13-dependency-management-rules.md create mode 100644 .agents/rules/15-shipped-runner-and-consumer-rules.md create mode 100644 .agents/rules/34-library-consumer-contract-rules.md create mode 100644 .agents/rules/40-skill-discovery-index-rules.md create mode 120000 .claude/agents/adversarial_pr_reviewer.md create mode 120000 .claude/agents/architecture_investigator.md create mode 120000 .claude/agents/ecosystem_contract_investigator.md create mode 120000 .claude/rules/01-jira-commit-standards.md create mode 120000 .claude/rules/02-security-and-paths.md create mode 120000 .claude/rules/04-build-test-and-deployment-rules.md create mode 120000 .claude/rules/05-ultimaker-skill-discovery-rules.md create mode 120000 .claude/rules/06-pull-request-lifecycle-rules.md create mode 120000 .claude/rules/07-owasp-security-rules.md create mode 120000 .claude/rules/08-scoped-changes-and-minimal-diffs.md create mode 120000 .claude/rules/09-atomic-bisect-safe-commits.md create mode 120000 .claude/rules/10-file-size-and-decomposition-rules.md create mode 120000 .claude/rules/12-ai-context-exclusion-rules.md create mode 120000 .claude/rules/13-dependency-management-rules.md create mode 120000 .claude/rules/15-shipped-runner-and-consumer-rules.md create mode 120000 .claude/rules/34-library-consumer-contract-rules.md create mode 120000 .claude/rules/40-skill-discovery-index-rules.md create mode 100644 .claude/settings.json create mode 120000 .github/agents/adversarial_pr_reviewer.md create mode 120000 .github/agents/architecture_investigator.md create mode 120000 .github/agents/ecosystem_contract_investigator.md create mode 100644 .github/copilot-instructions.md create mode 100644 .github/hooks/copilot-hooks.json create mode 120000 .opencode/agents/adversarial_pr_reviewer.md create mode 120000 .opencode/agents/architecture_investigator.md create mode 120000 .opencode/agents/ecosystem_contract_investigator.md create mode 120000 .opencode/rules/01-jira-commit-standards.md create mode 120000 .opencode/rules/02-security-and-paths.md create mode 120000 .opencode/rules/04-build-test-and-deployment-rules.md create mode 120000 .opencode/rules/05-ultimaker-skill-discovery-rules.md create mode 120000 .opencode/rules/06-pull-request-lifecycle-rules.md create mode 120000 .opencode/rules/07-owasp-security-rules.md create mode 120000 .opencode/rules/08-scoped-changes-and-minimal-diffs.md create mode 120000 .opencode/rules/09-atomic-bisect-safe-commits.md create mode 120000 .opencode/rules/10-file-size-and-decomposition-rules.md create mode 120000 .opencode/rules/12-ai-context-exclusion-rules.md create mode 120000 .opencode/rules/13-dependency-management-rules.md create mode 120000 .opencode/rules/15-shipped-runner-and-consumer-rules.md create mode 120000 .opencode/rules/34-library-consumer-contract-rules.md create mode 120000 .opencode/rules/40-skill-discovery-index-rules.md create mode 120000 .opencode/rules/agents.md create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 GEMINI.md create mode 100644 opencode.json diff --git a/.agents/agents/adversarial_pr_reviewer/agent.md b/.agents/agents/adversarial_pr_reviewer/agent.md new file mode 100644 index 0000000..4132298 --- /dev/null +++ b/.agents/agents/adversarial_pr_reviewer/agent.md @@ -0,0 +1,71 @@ +# Adversarial PR Reviewer Subagent Definition + +Name: adversarial_pr_reviewer +Description: Autonomous adversarial security and domain-expert code reviewer for this repository. + +## System Role & Instructions + +You are an adversarial, security-focused Senior Software Architect performing +autonomous code reviews for this repository. Every checklist item below cites a +rule file that exists in `.agents/rules/` — if a cited file is missing, that is +itself a finding. + +### Review Protocol & Verification Checklist + +1. **Security & Safety Guardrails**: + - **No Hardcoded Absolute Paths**: Ensure zero absolute user paths (`/home//`, `/Users//`). + - **No Leaked Secrets**: Scan for unencrypted private keys, tokens, passwords, or API keys. + - **OWASP Compliance**: Verify against the profile-matched sections in `.agents/rules/07-owasp-security-rules.md`. + +2. **Domain Architecture & Standards**: + +3. **Work Tracking & Commit Standards**: + - **Jira Reference**: Ensure commit titles and PR title start with bracketed Jira ticket prefix `[UC-123]`. + - **No Semantic Prefixes**: Reject `feat:`, `fix:`, `chore:` in commit/PR titles. + - **Minimal Diff & Scope Protection**: Reject mass re-formatting or edits to vendor SDKs (`vendor/`, `third_party/`). + - **Diff-vs-Message Honesty**: Diff every commit against its message. A commit whose diff contains changes its title does not describe (a functional fix inside a "revert"/"cleanup" commit) is a blocking finding, whatever the change's merit. + +### Bootstrap-Output Defect Taxonomy (mandatory for bootstrap/agentic-config PRs) + +Audit the change against the four classes every rollout defect fell into: + +- **(a) Template fit**: for each generated rule, hook, and section, name the + evidence in THIS repository that justifies it. Anything justified only by + "other repos have it" is flagged for omission. Hunt foreign-repo literals + (paths, service names, machine globs), contradicting rule pairs + (rebase-vs-merge, async-vs-sync), placeholder residue, dangling references + and dead links. +- **(b) Detector audit**: independently spot-check the profile's booleans + against the tree — above all, verify every "no X detected" claim (test + runners first; CI that runs tests refutes "no test runner detected"). +- **(c) Regeneration audit**: rules-manifest vs disk, rule-mirror set diff + across platform dirs, conflict markers, duplicate-top-level-key YAML, + orphaned platform-only files, hand-authored content at overwrite risk. +- **(d) Process audit**: staged paths vs the bootstrap commit allowlist, + commit-title uniqueness and Jira-key consistency, diff-vs-message honesty + for EVERY commit, no committed artifacts (`__pycache__`, screenshots, + submodule pointer dirt), and a V&V table backed by `hook_verification` + records in `.agents/bootstrap-profile.json`. + +Classify each prior review-comment resolution as **corrected vs deleted**: +resolving a comment by deleting the disputed content instead of fixing it is +itself a blocking finding. + +### Rerun the Gates Yourself + +Do not trust the orchestrator's word that gates passed — rerun them: + +```bash +python3 .agents/hooks/audit_quad_agent_parity.py . +grep -rn '{{\|TODO(agent)\|` for a human to resolve. Use the category `ecosystem_contract` +for anything crossing a repository boundary, `architecture` otherwise. + +**(c) A report** listing what you could not determine and why. + +### Honesty Requirements + +- **Never emit an unfilled placeholder.** No TODO marker, no ``, no empty + heading in AGENTS.md — the PR gate rejects all three, and rightly so. If you + cannot determine something, write the sentence: + "Not determined: `` — what was examined: ``; what would + settle it: ``." A stated gap is useful; a placeholder + teaches an agent that the document is approximate. +- **Evidence or it did not happen.** Every claim carries the path, the grep, or + the command output that supports it. A convention naming a symbol must show the + hit that proves the symbol exists. +- **Do not codify drift.** Frequent reverts, a sprawl of `*Manager` classes and + 1,600-line files are observations about what *is*, not evidence of what + *should be*. Where the signal looks like decay rather than design, say so. +- **Delegate the mechanical parts.** Repo-wide greps, file counts and import + graphs are cheap-model or scripted work. Spend your own effort on the judgement. + +### Output Format + +Return a structured Markdown report: + +- **Archetype** — one line, with the deciding evidence. +- **Layering & boundaries** — with the dependency direction and any violation found. +- **Contracts** — exposed / consumed / internal, each with its artifact and blast radius. +- **Invariants** — each with the command that proves it currently holds. +- **In-repo documentation mined** — path, what it states, and where it is now cited. +- **Written to AGENTS.md** — the exact section text. +- **Proposed observations** — entries appended to `.agents/bootstrap-observations.md`. +- **Not determined** — every open question, phrased so the next run can close it. diff --git a/.agents/agents/ecosystem_contract_investigator/agent.md b/.agents/agents/ecosystem_contract_investigator/agent.md new file mode 100644 index 0000000..f3f5031 --- /dev/null +++ b/.agents/agents/ecosystem_contract_investigator/agent.md @@ -0,0 +1,142 @@ +# Ecosystem Contract Investigator Subagent Definition + +Name: ecosystem_contract_investigator +Description: Finds the cross-repository surfaces of `python-quality-control` that no file-extension detector can see, and files them as `ecosystem_contract` observations. + +**Dispatch**: investigation phase (Phase 1), BEFORE generation, in parallel with +the architecture investigator. Capable model, medium-to-high effort for the +reasoning; hand the repo-wide greps to a cheap model or a script — they are +mechanical and should not consume judgement budget. Read-only, so no worktree is +needed until the observations file is written. Prepend +`.agents/dispatch-invariants.md` verbatim to this prompt. + +## System Role & Instructions + +You are investigating what `python-quality-control` promises to, and expects from, the +rest of the UltiMaker ecosystem. + +This charter exists because of a measured failure. An audit of eleven +bootstrapped repositories found that **every single observation in the corpus was +a git or file statistic** — commit body percentage, files per commit, filename +casing, class-suffix frequency. Not one described a D-Bus interface, a G-code +contract, the C++ to WASM to npm chain, a submodule pointer discipline, or a +deployment topology. The detectors match extensions and path segments; a contract +between repositories has neither. + +### 1. Start from the seeded surfaces, then go past them + +The detector has already filed `ecosystem_contract` observations in +`.agents/bootstrap-observations.md` for the surfaces it could see mechanically. + +Seeded here: + +- `This repository's public surface is mounted into other repositories` + +Each seeded entry names files and asks a question. Your job is to answer the +question with a specific fact, and to find the surfaces the scan missed. + +### 2. The hunt list + +For each item: is it present, who is on the other side, and what breaks silently? + +- **Inter-process interfaces** — D-Bus XML, protobuf, IDL. Which definitions does + this repository *own* versus carry as a generated proxy? What regenerates the + proxies, and which sibling repositories consume the same definition? +- **G/M-code surface** — does this repository send, forward, parse or handle + printer codes? Where is the registry of record, and which siblings sit on the + same path? Codes are an ecosystem contract; a locally added code that is not + registered is a defect on the day a sibling sees it. +- **WASM / embind boundary** — which link options and exported symbols are a + published contract rather than build tuning? Which flags does a consumer's + bundler configuration depend on (module format, single-file embedding, export + name)? Is any generated TypeScript surface typechecked by anything at all, on + either side of the boundary? +- **Bundler contracts** — a pre-bundling exclusion, an asset-loader entry or a + worker-inlining suffix in a build config is usually a scar from an integration + break. Find out which producing package requires it, and record the incident + next to the entry so a later cleanup does not delete it. +- **Publishing identity** — what artifact does this repository publish, under what + name, and where does the version string come from? A generated manifest means + there is no committed file for a detector to read; trace the recipe. +- **Version pinning direction** — who pins whom, and how tightly? An exact pin + (rather than a range) on an internal package usually encodes a specific upstream + commit. Confirm whether it is deliberate before anyone relaxes it. +- **Submodules** — the graph in both directions. Which repositories pin this one? + What is the ordering discipline for a change spanning both trees? +- **Deployment topology** — runtime, pipeline, and the message topics or endpoints + this repository publishes to and subscribes from. Name the services on the other + end. +- **Shared protocol and schema libraries** — a type, schema or constant defined in + one repository and depended on by several is a contract even when it looks like + an ordinary dependency. +- **Licence posture across the boundary** — a strong-copyleft component inside a + distributed or published artifact is an ecosystem fact, not a dependency detail. + +### 3. Read what the repository already documents + +Before writing any finding, read the in-tree documentation — README files inside +source directories, `docs/`, ADR trees, notes committed beside the code. Contract +knowledge is very often already written down by whoever got burned by it, filed +under a heading nobody would search for. Cite the document by path; do not +paraphrase it into a second source of truth that will drift. + +### 4. Verify both ends + +A contract claim with only one end observed is a guess. Where a sibling checkout +is available locally, open it and confirm; where it is not, say which end you did +not verify. Useful probes: + +```bash +gh search code "" --owner Ultimaker --limit 20 +gh api "repos/Ultimaker//contents/" -H "Accept: application/vnd.github.raw+json" +``` + +Neither is a substitute for reading the file when the checkout is at hand. + +### 5. Deliverables — observations, never rules + +Append entries to `.agents/bootstrap-observations.md` under the category +`ecosystem_contract`, in the shape that file already uses: + +- **Category** `ecosystem_contract`, and a confidence you can defend. +- **Evidence** — file paths on both sides, with line numbers where it matters. +- **Question to reason about** — the decision a human still has to make. +- **Draft rule** — keeping its `` where you could not resolve them. + A draft is a proposal; it becomes a rule only when a human or a later agent + promotes it into a custom band (`15`–`19`, `35`–`39`, `45`–`59`) with every + placeholder replaced. + +You do **not** write into `.agents/rules/`. The observation-to-rule ladder exists +so that a claim about another repository is reviewed by someone who can check it. + +Additionally, hand the architecture investigator a paragraph for AGENTS.md +*Position in the wider system*: what this repository consumes, what consumes it, +and the artifact that connects them. Facts only — obligations belong in a rule. + +Where a contract is already covered by a catalogue skill, say so and name the +skill rather than restating its content. A repository-local rule that duplicates +a skill drifts from it, and agents then follow the stale copy. + +### Honesty Requirements + +- **Never emit an unfilled placeholder outside a draft rule.** Draft rules keep + `` by design — that is how the ladder marks unfinished work. + Prose does not. If a fact is unknown, write "Not determined: `` — what + was examined: ``; what would settle it: ``." +- **Evidence or it did not happen.** Every claim carries the path or the command + output that proves it. +- **Absence is a claim too.** "No D-Bus surface in this repository" needs the + search that establishes it, and is worth recording — a later run should not + re-litigate it. + +### Output Format + +Return a structured Markdown report: + +- **Contracts found** — one block each: surface, direction, other end, artifact, + version-pinning mechanism, what breaks silently, evidence. +- **Surfaces checked and absent** — with the command that establishes the absence. +- **Observations appended** — the exact entries added to the observations file. +- **Skills that should have matched** — any catalogue skill this repository's + contracts imply but the matcher did not select, with the evidence. +- **Not determined** — open questions, phrased so the next run can close them. diff --git a/.agents/bootstrap-observations.md b/.agents/bootstrap-observations.md new file mode 100644 index 0000000..c8fa9a6 --- /dev/null +++ b/.agents/bootstrap-observations.md @@ -0,0 +1,126 @@ +# Custom Rule Proposals — python-quality-control + +These are observations about how *this* repository actually works, gathered by +the agentic bootstrap. They are **not rules yet**. Each one is a signal plus the +question it raises; an agent (or a human) has to decide whether the observation +deserves to become a rule, and what the rule should actually say. + +**How to act on this document** + +1. Work through the proposals below, highest confidence first. +2. For each, verify the evidence yourself — the detectors are heuristics and can + misread a repository. +3. For the ones worth keeping, write a rule file in `.agents/rules/` numbered + a custom band: `15`–`19` (always-on), `35`–`39` (path-scoped) or `45`–`59` + (model-decision). Managed bands (`01`–`14`, `20`–`34`, `40`–`44`) are + regenerated by the bootstrap and hash-guarded by the rules manifest. +4. Replace every `` in a draft with the concrete answer for this + repository. A rule that still contains a placeholder is worse than no rule. +5. Run `bash scripts/sync_agentic_configs.sh` so the new rule is symlinked into + `.claude/rules/` and `.opencode/rules/`. +6. Delete or annotate proposals you reject, so the next run does not re-litigate + a settled decision. + +**The `ecosystem_contract` category is different.** Those entries describe a +surface this repository shares with something outside it — an interface +definition, a command registry, a language boundary, a published package, a +submodule graph, a deployment topology. The detector can only see that the +surface exists; it deliberately does not claim to know what the contract is. +Answering that is the charter of +`.agents/agents/ecosystem_contract_investigator/agent.md`, which appends its +findings here, and of `.agents/agents/architecture_investigator/agent.md`, which +writes the architecture section of `AGENTS.md`. Both produce proposals and prose +— neither writes into `.agents/rules/`. A contract claim about another +repository is reviewed by a human before it becomes a rule. + +--- + +## 1. The repository documents its own architecture in-tree + +**Category:** architecture **Confidence:** high + +**Evidence** + +- `cfg/.pytest_cache/README.md` — 8 lines + +**Question to reason about** + +Read these before writing anything about this repository's architecture. Which of them state a convention that is a correctness requirement rather than a style preference, and which name a footgun a newcomer would hit? Anything already written here belongs in AGENTS.md as a CITATION, never as a paraphrase that will drift from the original. + +**Draft rule (needs review and placeholder resolution)** + +> Before changing ``, read `` — it is the authored convention for this subsystem. State the constraint it carries (``) and cite the document rather than restating it here. + +--- + +## 2. This repository's public surface is mounted into other repositories + +**Category:** ecosystem_contract **Confidence:** high + +**Evidence** + +- sibling checkouts pin this repository as a submodule: `libCharon` at `ci` + +**Question to reason about** + +Which consumers pin this repository, and what is the ordering discipline for a change that spans both trees? Rule 34 states the part that is provable from this tree; the rest belongs here. + +**Draft rule (needs review and placeholder resolution)** + +> Every exported symbol, model field and schema in the published surface is a contract with the repositories that mount it. Land the change here first, then move each consumer's pointer; prefer additive shape changes and name the consumers in the pull request. + +--- + +## 3. Commit messages carry explanatory bodies + +**Category:** commit-style **Confidence:** medium + +**Evidence** + +- 53% of sampled commits have a body +- average body length 3.8 lines + +**Question to reason about** + +Should agents be required to write a body explaining why/how, not just a subject line? + +**Draft rule (needs review and placeholder resolution)** + +> Every commit message MUST include a body explaining why the change was needed and how it works — a subject line alone is not enough. + +--- + +## 4. Commits are small and tightly scoped + +**Category:** commit-style **Confidence:** medium + +**Evidence** + +- median 1 files per commit, 90th percentile 4 + +**Question to reason about** + +Should the scope rule name a concrete file-count expectation derived from this repository's own history? + +**Draft rule (needs review and placeholder resolution)** + +> Keep commits tightly scoped: this repository's median commit touches 1 files. A commit touching more than 4 files needs an explicit justification in the message. + +--- + +## 5. History keeps merge commits + +**Category:** git-workflow **Confidence:** medium + +**Evidence** + +- 12/44 sampled commits are merges (mostly pull request merges) +- `Merge pull request`: 12, `Merge branch`: 0 + +**Question to reason about** + +Is the merge commit intentional (preserving branch topology), and should agents therefore NOT rebase published branches? + +**Draft rule (needs review and placeholder resolution)** + +> This repository preserves merge topology. Do not rewrite or rebase branches that have already been pushed; integrate with merges. diff --git a/.agents/bootstrap-profile.json b/.agents/bootstrap-profile.json new file mode 100644 index 0000000..e8171d5 --- /dev/null +++ b/.agents/bootstrap-profile.json @@ -0,0 +1,283 @@ +{ + "repository": { + "name": "python-quality-control", + "source": "git-remote:origin" + }, + "stacks": { + "has_frontend": false, + "has_cloud": false, + "has_cpp": false, + "has_infra": false, + "has_qml": false, + "has_freertos": false, + "has_debian": false, + "has_materials": false + }, + "git": { + "commits_sampled": 45, + "jira_keys": { + "CES": 10, + "UC": 1 + }, + "branch_jira_key": "UC", + "history_jira_key": "CES", + "dominant_jira_key": "UC", + "semantic_prefix_ratio": 0.0 + }, + "base_branch": "master", + "submodules": [], + "ci": { + "providers": [ + "github-actions" + ], + "workflows": [ + ".github/workflows/flake8.yml", + ".github/workflows/mypy.yml", + ".github/workflows/pycodestyle.yml", + ".github/workflows/pylint.yml", + ".github/workflows/pytest.yml", + ".github/workflows/vulture.yml" + ], + "signals": { + "tests": true, + "lint": true, + "build": false + } + }, + "tests": { + "frameworks": [ + "pytest" + ], + "commands": [ + "pytest -x -q cfg", + "./run_pytest.sh" + ] + }, + "linters": { + "ruff": false, + "black": false, + "flake8": false, + "mypy": true, + "eslint": false, + "prettier": false, + "stylelint": false, + "clang_format": false, + "clang_tidy": false, + "pre_commit_config": false + }, + "lint_scripts": [], + "protected_branches": [ + "master" + ], + "cpp": {}, + "c": {}, + "python": {}, + "ui": { + "has_ui": false, + "design_md_exists": false, + "token_sources": [], + "uses_stardust": false + }, + "pr_template": { + "exists": false, + "path": ".github/PULL_REQUEST_TEMPLATE.md", + "has_why": false, + "has_what": false, + "has_how": false, + "content": "Jira ticket: [{{ jira_example }}](https://ultimaker.atlassian.net/browse/{{ jira_example }})\n\n## Why\n\n\n## What\n\n\n## How\n\n\n## Verification & Validation (V&V)\n\n- [ ] Automated unit/integration tests pass\n- [ ] Pre-commit static checks pass cleanly on this branch's diff (`scripts/verify_and_create_pr.sh`)\n- [ ] Visual evidence attached for UI changes (if applicable)\n\n## PR Checklist\n- [ ] Initiating developer reviewed AI-generated code\n- [ ] No hardcoded absolute local paths or secrets introduced\n- [ ] Standing file-size and complexity budgets respected\n" + }, + "build_commands": [], + "directory_map": [ + { + "name": "cfg", + "count": 11, + "extensions": ".ini, .txt, .TAG" + }, + { + "name": "local", + "count": 9, + "extensions": ".sh" + }, + { + "name": "scripts", + "count": 2, + "extensions": ".py" + } + ], + "artifact_dirs": [], + "has_api_surface": false, + "interface_dirs": [], + "api_doc_paths": [], + "has_storybook": false, + "host": { + "os": "linux", + "has_secret_tool": true, + "has_security": false + }, + "vendored": [], + "generated": [], + "evidence": { + "has_systemd": false, + "systemd_units": [], + "has_udev": false, + "udev_rules": [], + "has_dbus": false, + "dbus_policy": [], + "has_machine_json": false, + "machine_json_files": [], + "has_gcode": false, + "gcode_evidence": [], + "has_po_files": false, + "po_files": [], + "has_debian_packaging": false, + "debian_evidence": [], + "shell_script_count": 18 + }, + "license": { + "declared": null, + "source": null, + "proprietary": false + }, + "has_package_json": false, + "file_count": 41, + "complexity": {}, + "skills": { + "catalog_root": "/tmp/uc-integrate", + "catalog_size": 28, + "matched": [ + "software-architect" + ] + }, + "observations": [ + { + "category": "architecture", + "title": "The repository documents its own architecture in-tree", + "evidence": [ + "`cfg/.pytest_cache/README.md` \u2014 8 lines" + ], + "question": "Read these before writing anything about this repository's architecture. Which of them state a convention that is a correctness requirement rather than a style preference, and which name a footgun a newcomer would hit? Anything already written here belongs in AGENTS.md as a CITATION, never as a paraphrase that will drift from the original.", + "draft_rule": "Before changing ``, read `` \u2014 it is the authored convention for this subsystem. State the constraint it carries (``) and cite the document rather than restating it here.", + "confidence": "high", + "data": { + "docs": [ + "cfg/.pytest_cache/README.md" + ] + } + }, + { + "category": "ecosystem_contract", + "title": "This repository's public surface is mounted into other repositories", + "evidence": [ + "sibling checkouts pin this repository as a submodule: `libCharon` at `ci`" + ], + "question": "Which consumers pin this repository, and what is the ordering discipline for a change that spans both trees? Rule 34 states the part that is provable from this tree; the rest belongs here.", + "draft_rule": "Every exported symbol, model field and schema in the published surface is a contract with the repositories that mount it. Land the change here first, then move each consumer's pointer; prefer additive shape changes and name the consumers in the pull request.", + "confidence": "high", + "data": { + "surface": "library_consumer_contract", + "consumers": [ + { + "repo": "libCharon", + "mount": "ci" + } + ], + "surface_dirs": [], + "manifests": [], + "has_entry_point": false, + "nested_checkout": false, + "paths": [ + "**/*.py" + ], + "reasons": [ + "sibling checkouts pin this repository as a submodule: `libCharon` at `ci`" + ] + } + }, + { + "category": "commit-style", + "title": "Commit messages carry explanatory bodies", + "evidence": [ + "53% of sampled commits have a body", + "average body length 3.8 lines" + ], + "question": "Should agents be required to write a body explaining why/how, not just a subject line?", + "draft_rule": "Every commit message MUST include a body explaining why the change was needed and how it works \u2014 a subject line alone is not enough.", + "confidence": "medium", + "data": {} + }, + { + "category": "commit-style", + "title": "Commits are small and tightly scoped", + "evidence": [ + "median 1 files per commit, 90th percentile 4" + ], + "question": "Should the scope rule name a concrete file-count expectation derived from this repository's own history?", + "draft_rule": "Keep commits tightly scoped: this repository's median commit touches 1 files. A commit touching more than 4 files needs an explicit justification in the message.", + "confidence": "medium", + "data": {} + }, + { + "category": "git-workflow", + "title": "History keeps merge commits", + "evidence": [ + "12/44 sampled commits are merges (mostly pull request merges)", + "`Merge pull request`: 12, `Merge branch`: 0" + ], + "question": "Is the merge commit intentional (preserving branch topology), and should agents therefore NOT rebase published branches?", + "draft_rule": "This repository preserves merge topology. Do not rewrite or rebase branches that have already been pushed; integrate with merges.", + "confidence": "medium", + "data": {} + } + ], + "hook_verification": { + "block-secrets": { + "fired": true, + "blocked": true, + "detail": "staged violation rejected", + "at": "2026-08-15T12:02:54+00:00", + "sha": "8aa6d9f" + }, + "block-absolute-paths": { + "fired": true, + "blocked": true, + "detail": "staged violation rejected", + "at": "2026-08-15T12:02:54+00:00", + "sha": "8aa6d9f" + }, + "pretool-guard": { + "fired": true, + "blocked": true, + "detail": "secret payload denied", + "at": "2026-08-15T12:02:54+00:00", + "sha": "8aa6d9f" + }, + "block-name-matched-kill": { + "fired": true, + "blocked": true, + "detail": "pkill denied", + "at": "2026-08-15T12:02:54+00:00", + "sha": "8aa6d9f" + }, + "check-rule-frontmatter": { + "fired": true, + "blocked": null, + "detail": "exit 0 (advisory; executed, not violated)", + "at": "2026-08-15T12:02:54+00:00", + "sha": "8aa6d9f" + }, + "audit-quad-agent-parity": { + "fired": true, + "blocked": null, + "detail": "exit 0 (advisory; executed, not violated)", + "at": "2026-08-15T12:02:54+00:00", + "sha": "8aa6d9f" + }, + "check-upstream-alignment": { + "fired": true, + "blocked": null, + "detail": "exit 0 (advisory; executed, not violated)", + "at": "2026-08-15T12:02:55+00:00", + "sha": "8aa6d9f" + } + } +} diff --git a/.agents/dispatch-invariants.md b/.agents/dispatch-invariants.md new file mode 100644 index 0000000..4902814 --- /dev/null +++ b/.agents/dispatch-invariants.md @@ -0,0 +1,33 @@ +# Dispatch Invariants + + + +1. **Jira key**: every commit title in this session uses `[UC-123]`, + verbatim. Never substitute another project key, whatever the history shows. +2. **Base branch**: this repository's PRs target `master`. Diff, + rebase-check and align against `origin/master` only. +3. **Commit path allowlist**: a bootstrap commit stages only + `.agents/**`, `.claude/**`, `.github/**`, `.opencode/**`, `AGENTS.md`, + `GEMINI.md`, `DESIGN.md`, `.aiignore`, `.ignore`, `.gitattributes`, + `.gitignore`, `.pre-commit-config.yaml`, `.talismanrc`, `opencode.json`, + `CLAUDE.md`, `linting_excluded_files.txt`, + `scripts/sync_agentic_configs.sh`, `scripts/verify_and_create_pr.sh`. + Any other staged path — reformats, submodule pointer bumps, functional + fixes — is a hard stop; extract it to its own branch. +4. **Worktree isolation**: any mutating work (commits, builds, file edits) + requires `isolation: worktree`. Never mutate a shared checkout. **Never run + `git stash` in a worktree**: `refs/stash` is a single ref shared by every + worktree of a clone, so a pop in one tree replays — and destroys — another + agent's work. To compare against other code use `git show :` or + copy the file aside. +5. **Foreground long-running commands**: never background a long-running + command expecting a completion notification; none will come. +6. **Evidence or it did not happen**: report every claim with the command + output that proves it. An assertion without its command is discarded. +7. **No repo-wide formatters**: never run `pre-commit run --all-files`, + `ruff format .`, or any formatter over paths the task does not touch. +8. **Conflict markers are a hard stop**: never commit a half-merged file; + regenerate structured configs from source instead of text-merging them. diff --git a/.agents/rules-manifest.json b/.agents/rules-manifest.json new file mode 100644 index 0000000..0d1d04f --- /dev/null +++ b/.agents/rules-manifest.json @@ -0,0 +1,17 @@ +{ + "rules": { + "01-jira-commit-standards.md": "0fae33088a2063cf57a2b9b7189b6ca70ec16e394b8c152d04ef289bf04e4a5f", + "02-security-and-paths.md": "f18b11b8ff318e969f812803a72c5d45fe84b78a5d6107bb41a447306de80f93", + "04-build-test-and-deployment-rules.md": "50ff331d337857475dfb6fa545e6d557ab5d5f67e700f94184beb8addced7757", + "05-ultimaker-skill-discovery-rules.md": "0bfb0df7e64c831eea3630263fed95d2d39444eddd1ecfc55fda21bea92d4fc4", + "06-pull-request-lifecycle-rules.md": "2893b4c4f969250c6065d27f108c5547d1926314fd8845a01ef88f4545e95664", + "07-owasp-security-rules.md": "d893a45caf28e31945948f52868ed7cd6dcd84481900fab2407575bd9b28f2bd", + "08-scoped-changes-and-minimal-diffs.md": "fc397b61f3f2ddc51736934cc02248d6d188c31b7680977dfb66704e060f310b", + "09-atomic-bisect-safe-commits.md": "028d1225bd1ad8f75534eb069478fab2864e8530b7b75f14375c26a01d9dbfd9", + "10-file-size-and-decomposition-rules.md": "eca20f37445be2691c51560df954bebc7b1baa7b542329db13608c948afcab0a", + "12-ai-context-exclusion-rules.md": "dc3997cdaf749718dc3986453d72b889655f55b15f781f4042fc1b551269d946", + "13-dependency-management-rules.md": "5892f7965fbed9c019deee732a33e413adf74b473f85e05896887bbb5a500ce8", + "34-library-consumer-contract-rules.md": "c46fac292164c4dea413d8cbee032acb75828306e61026b07e254245d1c8d1f9", + "40-skill-discovery-index-rules.md": "bb5bd05d14b21fa4f09397f77830b7426fd21d2ff842b3a748413cdb3038217c" + } +} diff --git a/.agents/rules/01-jira-commit-standards.md b/.agents/rules/01-jira-commit-standards.md new file mode 100644 index 0000000..fe49e3e --- /dev/null +++ b/.agents/rules/01-jira-commit-standards.md @@ -0,0 +1,15 @@ +--- +name: jira-commit-standards +description: Jira work tracking and commit message standards. +trigger: always_on +--- +# Jira & Git Commit Standards + +1. **Jira Work Tracking**: + - All branches MUST reference an active Jira ticket starting with project key `UC` (e.g. `UC-123-short-description`). +2. **Commit Title Standard**: + - Every commit title MUST start with bracketed Jira ticket key: `[UC-123] `. + - Do NOT use semantic commit prefixes (`feat:`, `fix:`, `chore:`, `refactor:`) in commit or PR titles. +3. **Pull Request Policy**: + - Always open PRs in **DRAFT** state. + - Merging is strictly restricted to human developers. diff --git a/.agents/rules/02-security-and-paths.md b/.agents/rules/02-security-and-paths.md new file mode 100644 index 0000000..511fe62 --- /dev/null +++ b/.agents/rules/02-security-and-paths.md @@ -0,0 +1,21 @@ +--- +name: security-and-paths +description: Security guidelines, secret protection, and path sanitation. +trigger: always_on +--- +# Security & Path Protection Guidelines + +1. **No Hardcoded Absolute Paths**: + - Never commit absolute local filesystem paths (e.g. `//` or `//`). +2. **No Secret Leaks**: + - Never commit private keys, API tokens, or passphrases. + - Use RAM-backed filesystem mounts (`/dev/shm`) for temporary secret processing. +3. **No Security Feature Downgrades**: + - Agents are **STRICTLY FORBIDDEN** from disabling, weakening, or bypassing security, authentication, or authorization controls — for example switching `signInRequired`, `needs_authentication`, `verify`, `ssl_verify`, `check_permissions` or `authorized` to a falsy value, or skipping scope checks — to bypass test failures or browser verification roadblocks. + - This rule is written WITHOUT the literal downgrade assignments on purpose: `check_security_downgrades` scans every added line, and a rule document that spelled out `=false` would flag itself on the commit that introduced it. + - All authentication features and security parameters must remain intact and fully enforced. +4. **Branch Guard**: + - Direct commits to `main`, `master`, or `staging` branches are strictly forbidden. + +5. **PII & Secret Redaction in Logging**: + - Never write PII, passwords, credentials, API tokens, or session keys to log files, stdout, or debug output. diff --git a/.agents/rules/04-build-test-and-deployment-rules.md b/.agents/rules/04-build-test-and-deployment-rules.md new file mode 100644 index 0000000..9a5fb56 --- /dev/null +++ b/.agents/rules/04-build-test-and-deployment-rules.md @@ -0,0 +1,25 @@ +--- +name: build-test-and-deployment +description: Build, test, and deployment verification commands detected for this repository, with Freshness Before Evidence rules. +trigger: always_on +--- +# Build, Test & Deployment Verification + +1. **Package Registry Authentication**: + - Obtain `GITHUB_TOKEN` (scope `read:packages`) for private `@ultimaker` packages via the keyring-first chain in `scripts/get_github_token.sh` — source it (`. scripts/get_github_token.sh`) instead of hand-exporting: (a) an existing env var wins, (b) else the system keyring (Linux: `secret-tool lookup service github user "$USER"`), (c) else a gitignored `.env`/`.env.local` fallback with a warning. Store it once per machine, user-specific — never a hardcoded username: `echo -n "" | secret-tool store --label="$USER-github-token" service github user "$USER"`. Never write tokens to disk, logs, or git. +2. **Test Commands (detected)**: + - `pytest -x -q cfg` + - `./run_pytest.sh` +3. **Artifact Isolation**: + - Keep generated build outputs, intermediate binaries, and logs out of git. Ensure `.env` and `.env.local` files remain strictly gitignored. +4. **Freshness Before Evidence**: + - Rebuild binaries/packages/containers before treating a behavioral observation as evidence. An observation is only valid if the artifacts observed were compiled from the current source state. An unverified stale build is a false observation. + + +## Semantic Release & Version Tagging + +1. **SemVer Version Tagging**: + - Releases MUST follow Semantic Versioning (`MAJOR.MINOR.PATCH`). + - Tags MUST be created on main branch commits after PR integration. +2. **Automated Changelog Generation**: + - Changelogs are generated directly from Pull Request titles. Ensure PR titles follow the `[UC-123] ` standard. diff --git a/.agents/rules/05-ultimaker-skill-discovery-rules.md b/.agents/rules/05-ultimaker-skill-discovery-rules.md new file mode 100644 index 0000000..47db28d --- /dev/null +++ b/.agents/rules/05-ultimaker-skill-discovery-rules.md @@ -0,0 +1,47 @@ +--- +name: ultimaker-skill-discovery +description: Skills from the UltiCortex catalogue that apply to this repository, and when to load them. +trigger: always_on +--- +# UltiMaker Skill Discovery & Usage + +This repository has been matched against the UltiCortex skill catalogue. Loading +the relevant skill is **not optional** for the work it covers: these skills carry +the standards, idioms, and tooling knowledge that the rules in this directory +assume you already have. + +```bash +# Search the catalogue +gh skill search ultimaker --owner Ultimaker + +# Install a specific skill +gh skill install Ultimaker/UltiCortex +``` + +Load the skill **before** designing or implementing, not after review comments +arrive. If a skill contradicts a rule in this directory, raise the conflict +rather than silently picking one. + +## Skills Matched To This Repository + +### `software-architect` — always relevant + +Expert software-architecture advisor: design patterns (GoF), SOLID/DRY, enterprise patterns (Fowler PoEAA), DDD/CQRS, evolutionary architecture (monolith-first, strangler fig, microservices), distributed systems, C4 diagrams, C++…. + +**Why it applies here:** SOLID, DRY and the design-pattern catalogue — the reference to consult when a change needs decomposition rather than more lines in an existing module. + +```bash +gh skill install Ultimaker/UltiCortex software-architect +``` + +## Other Catalogue Skills + +26 further skill(s) exist that no automatic trigger matched. +They are indexed in `.agents/rules/40-skill-discovery-index-rules.md` (a +model-decision rule, loaded only when judged relevant) rather than here, +because by construction they are the ones static detection judged irrelevant — +and this file is loaded every session. + +```bash +gh skill search ultimaker --owner Ultimaker +``` diff --git a/.agents/rules/06-pull-request-lifecycle-rules.md b/.agents/rules/06-pull-request-lifecycle-rules.md new file mode 100644 index 0000000..907f297 --- /dev/null +++ b/.agents/rules/06-pull-request-lifecycle-rules.md @@ -0,0 +1,36 @@ +--- +name: pull-request-lifecycle +description: Pull request lifecycle rules, draft PR policy, PR template enforcement, review workflows, and subagent delegation guardrails. +trigger: always_on +--- +# Pull Request Lifecycle Rules + +1. **Pre-PR Verification & Gate**: Run `scripts/verify_and_create_pr.sh` (pre-commit + orientation check + adversarial audit) before creating or updating any PR. +2. **Draft PR Policy**: Always open PRs in **DRAFT** state (`gh pr create --draft`). Merging is strictly restricted to human developers; AI agents must never auto-merge. +3. **Mandatory PR Template & Comprehensive Description**: + - Every PR description **MUST** strictly follow the repository's PR template (its location is recorded as `pr_template.path` in `.agents/bootstrap-profile.json`; GitHub also honours a repository-root or `docs/` template) and answer the core review questions: + - **Why**: The problem, user request, Jira ticket (`[UC-123]`), and business context driving the change. + - **What**: High-level overview of introduced changes. + - **How**: Architecture decisions, implementation details, and modified modules. + - **Verification & Validation (V&V)**: Empirical test results (unit tests, integration tests, E2E checks, and visual screenshots/recordings for UI changes). + - **PR Checklist**: Human reviewer checklist (`- [ ] Initiating developer reviewed AI-generated code`). + - Vague, brief, or 1-sentence PR descriptions are strictly prohibited. +4. **Empirical Proof Mandate**: Verification is ONLY valid when concrete empirical proof (a DOM text snapshot, test execution log, or screenshot uploaded via `gh image` attached to the walkthrough and PR body) is delivered. Agents must NEVER claim a UI feature or fix is verified without delivering empirical proof. +5. **Updating Existing PRs on Follow-up Commits**: + - When pushing follow-up commits to an active branch with an existing Pull Request, agents **MUST** inspect the existing PR (`gh pr view` or `gh pr list --head `). + - If the new commits add new scope, alter architecture (**How**), or require updated testing/screenshots (**V&V**), run `gh pr edit --body-file ` to update the PR description so it always reflects the current state of the branch. +6. **CI Watch Loop**: After creating or updating a PR, actively monitor status checks (`gh pr checks --watch`) and fix any linter or test failures immediately before handing off to human review. +7. **Upstream Base Branch Alignment**: + - Before staging changes, opening PRs, or pushing follow-up commits, agents **MUST** ensure the local feature branch is completely up-to-date with its base branch (`origin/staging`, `origin/main`, or `origin/master`). + - Run `git fetch origin` and `git merge origin/` (or use `/sync-base` command) to resolve any upstream changes or conflicts before proposing PR updates. +8. **Subagent Delegation Guardrails**: + - Delegation moves the work, not the responsibility. Everything a subagent reports is a claim until verified via `git log` and `git diff` in the subagent's tree. + - Specify the target PR state explicitly in subagent instructions (draft vs ready). Verify that subagents commit and push all work before reporting completion. + + +## Git Merge Topology Preservation + +This repository preserves merge topology for pull requests and feature branches to maintain ISO-27001 auditability and review history: +1. **No Force-Pushing Published Review Branches**: Do NOT rewrite history or force-push rebased commits on published branches that have already been opened for PR review. +2. **Integrate Upstream via Merge**: Integrate upstream base branch updates using `git merge origin/main` (or `/sync-base`) rather than rebasing published history. +3. **Draft PR Lifecycle**: PRs must remain in DRAFT status until all status checks pass and human review is complete. Merging is strictly restricted to human developers. diff --git a/.agents/rules/07-owasp-security-rules.md b/.agents/rules/07-owasp-security-rules.md new file mode 100644 index 0000000..e1bfc79 --- /dev/null +++ b/.agents/rules/07-owasp-security-rules.md @@ -0,0 +1,17 @@ +--- +name: owasp-security +description: OWASP security standards selected for this repository's detected stack profile. +trigger: always_on +--- +# OWASP Security Guidelines (Profile-Matched) + +These sections were selected because the bootstrap investigation detected the matching stack. Enforcement is layered: these rules guide implementation, pre-commit hooks block secrets/paths mechanically, and `scripts/verify_and_create_pr.sh` runs the adversarial audit before any PR. + +## Secure Coding Essentials + +1. **No hardcoded credentials**: never commit passwords, private keys, API tokens or HMAC secrets. Load them from the environment or a secret manager at runtime. +2. **Input validation**: validate and sanitise anything that crosses a trust boundary — user input, file contents, network payloads, subprocess arguments. +3. **Injection prevention**: parameterise database queries and never build shell commands by string concatenation from untrusted values. +4. **Privacy**: never write personal data, passwords or tokens to logs, telemetry or debug output. +5. **Dependency hygiene**: keep dependencies pinned and patched; check advisories before adding one. +6. **Error hygiene**: do not leak stack traces, internal paths or configuration in errors returned across a boundary. diff --git a/.agents/rules/08-scoped-changes-and-minimal-diffs.md b/.agents/rules/08-scoped-changes-and-minimal-diffs.md new file mode 100644 index 0000000..54c7b8b --- /dev/null +++ b/.agents/rules/08-scoped-changes-and-minimal-diffs.md @@ -0,0 +1,41 @@ +--- +name: scoped-changes-and-minimal-diffs +description: Scoped changes and minimal diff guidelines for code changes. +trigger: always_on +--- +# Scoped Changes & Minimal Diffs + +1. **Strict Scope Compliance**: Make changes strictly relevant to the active Jira ticket task. Avoid scope creep. +2. **Diff Relevance Validation**: Inspect `git diff --name-only` against the base branch before committing. Revert files touched only by formatters or side-effects: `git checkout origin/ -- `. +3. **No Unrelated Refactoring**: Do not modify whitespace, formatting, or code in files unrelated to the task. Never edit `vendor/`, `third_party/`, or submodule trees. + + +## Single Responsibility PRs & Boy Scouting Branch Isolation + +Every Pull Request MUST deliver a single cohesive goal tied to a single primary topic or Jira issue. Combining multiple unrelated features, bug fixes, or opportunistic refactorings ("Boy Scouting") into a single PR creates scope coupling and increases reviewer cognitive load. + +## 1. Single Responsibility Principle for PRs (SRP-PR) + +- **One Goal per PR:** A Pull Request MUST serve a single, clear objective. +- **Intra-Module Intent Divergence:** Even if all code changes are located within the exact same directory or module (e.g. `src/components/` or `griffin/printer/`), edits MUST NOT fix multiple unrelated bugs or introduce opportunistic refactorings alongside a new feature. +- **Single Jira Key:** A PR SHOULD address a single Jira ticket key. Do NOT combine work for `UC-100` and `UC-200` into one PR. + +## 2. Boy Scouting Isolation Protocol + +When you discover an opportunistic bug, missing setting, or code cleanup ("Boy Scouting") while working on a primary task: + +1. **Do NOT lump the opportunistic changes into the primary feature PR.** +2. **Extract onto a Boy Scouting Branch:** + ```bash + bash scripts/create_boyscout_branch_and_pr.sh + ``` +3. **Open a Dedicated Draft PR:** Push the Boy Scouting branch to GitHub and open an independent Draft PR (`gh pr create --draft`). +4. **Link PRs in Descriptions:** Reference the Boy Scouting PR in your primary PR description so reviewers can inspect both independently. + +## 3. Adversarial Scope Verification + +Before submitting or updating a Pull Request, run the Adversarial Scope Judge: +```bash +python3 .agents/hooks/check_multi_intent_scope.py +``` +It blocks exactly one thing — edits to trees this repository vendors but does not own — and otherwise **reports** the changed-file list grouped by area. It deliberately does not decide for you: a rename touches sixty files with one intent, while two files in two subsystems can still be two intents. Read the report and judge it; if part of the diff is opportunistic, extract it before requesting human review. diff --git a/.agents/rules/09-atomic-bisect-safe-commits.md b/.agents/rules/09-atomic-bisect-safe-commits.md new file mode 100644 index 0000000..b92bf3d --- /dev/null +++ b/.agents/rules/09-atomic-bisect-safe-commits.md @@ -0,0 +1,17 @@ +--- +name: atomic-bisect-safe-commits +description: Atomic, bisect-safe commit rules. +trigger: always_on +--- +# Atomic & Bisect-Safe Commits + +1. **Atomic Commits**: Each commit must be a single self-contained, logical unit of work that compiles and passes tests independently. +2. **Bisect-Safe**: Never break the build or unit test suite in intermediate commits to preserve `git bisect` functionality. +3. **History Cleanup**: Squash WIP/fixup commits (`git rebase -i`) before a PR leaves DRAFT. +4. **Prove it, do not assert it**: "bisect-safe" is a claim about a build, not about a commit message. The pre-push hook runs this repository's own build/test command against the committed tree and fails the push if the tip does not build: + + ```bash + python3 .agents/hooks/check_atomic_bisect_history.py + ``` + + Export `SKIP_BISECT_BUILD=1` only when you are knowingly pushing a tip you already know is broken — and say so in the pull request. diff --git a/.agents/rules/10-file-size-and-decomposition-rules.md b/.agents/rules/10-file-size-and-decomposition-rules.md new file mode 100644 index 0000000..1586c87 --- /dev/null +++ b/.agents/rules/10-file-size-and-decomposition-rules.md @@ -0,0 +1,91 @@ +--- +name: file-size-and-decomposition +description: File-size budget with grandfathering ratchet, and the decomposition expected to meet it. +trigger: always_on +--- +# File Size Budget & Decomposition Rules + +A large file is expensive for every agent that reads it afterwards. This +repository enforces a budget of **400 lines**, with a ratchet so that +existing large files are not a blocker but can never get worse. + +## 1. The Two Tiers + +1. **Files within budget** must stay at or under 400 lines. +2. **Files already over budget** when the ratchet was introduced are recorded in + `.agents/file-size-baseline.json` at their size at that moment. They **may + shrink but must never grow**. When one shrinks, its ceiling tightens + automatically — the reclaimed space cannot be spent later. +3. **New files are never grandfathered.** A file created from now on must meet + the budget outright. + +Check status at any time: + +```bash +python3 .agents/hooks/check_file_size_budget.py --report +``` + +## 2. During Design and Planning — Before Writing Code + +Treat the budget as a design input, not a gate you discover at commit time. + +- Run the `--report` command above on every file the change is expected to + touch, and read the headroom before deciding where code goes. +- If the planned work does not fit the headroom, the plan must say **which + responsibility moves out, where it goes, and what the new module is called**. + Decide this during design; do not defer it until the hook fails. +- When a task's natural home is a file already at its ceiling, the default + answer is a new module, not an exception. +- State the intended decomposition in the implementation plan and in the pull + request description, so a reviewer sees the structural intent rather than an + unexplained new file. + +## 3. Meeting the Budget Honestly + +Reducing the line count without reducing complexity is a violation of this rule, +even when the number goes down. The following are **not** acceptable ways to +pass the check: + +- deleting blank lines or collapsing formatting +- inlining variables, shortening identifiers, or packing statements onto one line +- moving code into comments, or relocating it to an already-oversized file +- disabling or excluding the check for the file + +Reduce the file by moving responsibility out of it: + +1. **Single Responsibility (SRP)** — enumerate the distinct reasons the file has + to change. Each separate reason belongs in its own module. +2. **Open/Closed (OCP)** — find the conditional or `switch` that grows whenever a + case is added, and replace it with polymorphism (Strategy) or a + registry/Factory, so future cases are added without editing this file. +3. **DRY** — extract logic that is repeated inside the file or duplicated + elsewhere in the codebase. +4. **Dependency direction** — separate I/O, parsing, and configuration from core + logic so each side is testable on its own. +5. **Interface Segregation / composition** — split a class that serves several + callers with disjoint needs, rather than growing one wide interface. + +Load the **`software-architect`** skill when deciding how to split a file: it +carries the SOLID guidance and the design-pattern catalogue (Facade, Strategy, +Observer, Factory) that these steps refer to. Reach for it during design, not +after the hook rejects the commit. + +## 4. Enforcement + +- **Pre-commit** blocks a commit that pushes a file over budget or grows a + grandfathered file, and tightens ceilings for files that shrank. +- **Agent PostToolUse hooks** report the same violation immediately after an + edit, so the problem surfaces while the context is still open. +- `.agents/file-size-baseline.json` is committed. Do **not** hand-edit the + `files` map to excuse a violation; entries are removed automatically once a + file is within budget. + +Generated files (`@generated`, `DO NOT EDIT` headers) and third-party code +(recognised vendor copyright headers, `vendor/`, `third_party/`, submodules) are +neither checked nor grandfathered — they are not ours to decompose. If vendored +code still slips into the baseline, add a path fragment to the `exclude` list in +`.agents/file-size-baseline.json` and re-run: + +```bash +python3 .agents/hooks/check_file_size_budget.py --init +``` diff --git a/.agents/rules/12-ai-context-exclusion-rules.md b/.agents/rules/12-ai-context-exclusion-rules.md new file mode 100644 index 0000000..e413a2f --- /dev/null +++ b/.agents/rules/12-ai-context-exclusion-rules.md @@ -0,0 +1,44 @@ +--- +name: ai-context-exclusion +description: What must never be read by an AI agent in this repository, how exclusion is enforced, and proactive .aiignore maintenance. +trigger: always_on +--- +# AI Context Exclusion + +`.aiignore` at the repository root is the single source of truth for files that +must not enter a model's context: secrets, third-party code and SDKs, build +output, large binaries, and anything carrying personal data. + +## How it is enforced + +No agent platform reads `.aiignore` natively. `.agents/hooks/compile_aiignore.py` +translates it into the mechanism each platform actually honours: + +| Platform | Mechanism | +|---|---| +| Antigravity / ripgrep-based search | `.ignore` (generated) | +| Claude Code | `permissions.deny` `Read(./…)` rules in `.claude/settings.json` | +| OpenCode | `permission.read` / `glob` / `grep` deny map in `opencode.json` | +| GitHub Copilot | org-level content exclusion, applied server side — paste `.github/copilot-content-exclusion.yml` into GitHub settings | + +## Rules for agents + +1. **Never read, quote, or summarise a file matching `.aiignore`.** If a task + appears to require one, stop and say so rather than working around the + exclusion. +2. **Never weaken the exclusion to finish a task** — do not delete patterns, + add negations, or bypass the derived deny rules. +3. **Edit `.aiignore`, never the generated targets.** `.ignore` and + `.github/copilot-content-exclusion.yml` are overwritten by the compiler, and + the deny rules in the platform configs are rewritten in place. +4. **After changing `.aiignore`, run the compiler and commit the results + together**, or pre-commit will reject the change as out of date: + + ```bash + python3 .agents/hooks/compile_aiignore.py + ``` +5. **Copilot exclusion is not active until a human applies it in GitHub.** + Adding a secret pattern to `.aiignore` does not retroactively hide it from + Copilot; treat any exposed credential as compromised and rotate it. +6. **Proactive AI-Ignore Maintenance**: + Maintain `.aiignore` whenever introducing new heavy, generated, log, or vendor artifacts. Run `python3 .agents/hooks/compile_aiignore.py` immediately to recompile platform deny rules. diff --git a/.agents/rules/13-dependency-management-rules.md b/.agents/rules/13-dependency-management-rules.md new file mode 100644 index 0000000..2a73342 --- /dev/null +++ b/.agents/rules/13-dependency-management-rules.md @@ -0,0 +1,120 @@ +--- +name: dependency-management +description: Reuse before rebuild — search existing and published dependencies, and check licence compatibility, before writing new code. +trigger: always_on +--- +# Dependency Management — Do Not Reinvent the Wheel + +Hand-rolled implementations of solved problems are the most expensive code in a +repository: they carry no upstream security fixes, no community documentation, +and no tests but the ones you happen to write. Before implementing any +non-trivial capability, establish that it does not already exist. + +This applies at **four** points, not just while typing. + +## 1. During Design + +Before choosing an approach, state in the design or plan **which existing +dependency provides this, or why none does**. "We will write our own X" is a +decision that needs a reason — an unmet requirement, a licence conflict, or an +unmaintained ecosystem — not a default. + +## 2. Before Adding Anything — Check What Is Already Here + +The cheapest dependency is one already in the manifest: no new supply chain, no +new licence, no new review. + +- **Python** — already-declared dependencies live in `pyproject.toml / requirements.txt`. Search them first: + ```bash + pip list # or: uv pip list + ``` + +Also check the internal ecosystem: a sibling UltiMaker repository or a shared +library may already solve this, and reusing it keeps behaviour consistent across +products. + +## 3. If Nothing Exists Internally — Search the Registry + +- **Python**: + ```bash + pip index versions + # Inspect metadata and licence before adding: + pip show # or: uv add --dry-run + ``` + +Judge a candidate on evidence, not popularity alone: + +- **Maintenance**: recent releases, issues being answered, no unpatched CVEs. +- **Fit**: solves the actual problem without dragging in a framework. +- **Weight**: for frontend code, check the bundle cost; for embedded and WASM + targets, check binary size and whether it allocates. +- **Transitive cost**: a package with a large dependency tree imports every one + of that tree's licences and vulnerabilities too. + +Prefer the option this repository or its siblings already use over an equivalent +alternative — consistency is worth more than a marginal feature advantage. + +## 4. Licence Compatibility — Check Before Adding, Not After + +> [!WARNING] +> No project licence was detected. Establish and declare it before adding +> dependencies — you cannot judge compatibility against an unknown licence. + + +Verify that each new dependency's licence is compatible with this project's +licence and its distribution model. Strong copyleft licences (GPL, AGPL) impose +obligations on distributed software; a package with no declared licence is "all +rights reserved" and cannot be used at all. + +Record the licence of every dependency you add. If you cannot determine it, that +is itself a blocker. + +## 5. When Opening the Pull Request + +Any new dependency must be called out explicitly in the PR description with: + +- **What it replaces** — the code you did not write. +- **Why this one** — maintenance status and the alternatives rejected. +- **Its licence**, and why that is compatible with the project licence. +- **Its transitive footprint** — how many packages it actually pulls in. + +Pin the version, commit the updated lockfile in the same change, and never add a +dependency as a drive-by in a change about something else. + + +## Industry Best Practices & GitHub Code Search Rules + +1. **Industry Best Practices First**: + - Before implementing complex algorithms, security/crypto routines, hardware protocol drivers, or architectural abstractions, agents **MUST investigate industry best practices and existing open-source implementations**. + - Do NOT write bespoke, custom implementations when verified, licensed open-source packages, standard library routines, or internal UltiMaker utilities already exist. + +2. **Multi-Dimensional Code Evaluation (`code-best-practices-search`)**: + - Load and execute `UltiCortex/skills/software/code-best-practices-search` when researching architectural design patterns, security postures, or benchmarking open-source implementations. + - Candidate implementations MUST be evaluated across 7 core dimensions: + 1. **Security Posture** (OWASP, parameterization, input sanitization, safe memory bounds) + 2. **Reliability & Resilience** (Fail-secure behavior, explicit error propagation, zero swallowed exceptions) + 3. **Agentic Readiness** (Decomposed <400 line modules, strongly typed signatures, decoupled seams for AI testing) + 4. **Execution Efficiency** (Async/concurrency models, thread safety, memory allocation footprint) + 5. **Dependency Weight** (Minimal third-party dependencies, standard library utilization) + 6. **Coupling & Cohesion** (SOLID principles, DRY, separation of domain logic from boilerplate) + 7. **Tech Stack Fit** (Strict alignment with target codebase rules and standards) + +3. **Skill Discovery & Execution (`github-search-code`)**: + - Load and execute `UltiCortex/skills/software/github-search-code` when executing precise `gh search code` CLI queries, handling exclusions (`-filename:` vs `-path:`), or using `-w` for browser regex search. + - Use the GitHub CLI (`gh search code`) to audit existing code bases, discover architectural patterns, and extract verified idioms across the organization and GitHub: + ```bash + # Search across the organization for existing code patterns or utilities + gh search code "search_term" --owner="Ultimaker" --language="python" + + # Search for specific cryptographic or security configurations + gh search code "crypto/md5" --owner="Ultimaker" --language="go" + + # Exclude test files using query syntax with -- + gh search code -- "function language:python -filename:test" + ``` + +4. **Programmatic Hydration & Raw Blob Review**: + - Use the `search_and_hydrate.sh` script or `gh api` with `-H "Accept: application/vnd.github.raw+json"` to fetch untruncated file blobs and perform pairwise Elo evaluations across implementations: + ```bash + UltiCortex/skills/software/code-best-practices-search/scripts/search_and_hydrate.sh "ClientName" --language python -L 5 + ``` diff --git a/.agents/rules/15-shipped-runner-and-consumer-rules.md b/.agents/rules/15-shipped-runner-and-consumer-rules.md new file mode 100644 index 0000000..4889048 --- /dev/null +++ b/.agents/rules/15-shipped-runner-and-consumer-rules.md @@ -0,0 +1,55 @@ +--- +name: shipped-runner-and-consumer +description: This repository is the shared CI configuration. The run_*.sh scripts are the product, not a local test suite, and the consumers are firmware and cloud repositories. +trigger: always_on +--- + +# The Runners Are The Product + +This repository holds no application code. It holds the linting and test +configuration that other repositories mount as a git submodule named `ci`. + +## 1. This repository has no test suite of its own + +`git ls-files` returns no test file and no `conftest.py`. `cfg/` holds only +`.ini` and `.txt` files. + +Rule `04-build-test-and-deployment-rules.md` lists two detected test commands. +Neither command tests this repository. Do not run them here. + +- `pytest -x -q cfg` collects nothing and exits with "no tests ran". +- `./run_pytest.sh` fails with `FileNotFoundError` for `./ci/cfg/pytest.ini`. + That path exists only inside a consumer, where this repository is mounted + at `ci`. + +Verify a change to a runner script in a consumer checkout, or with +`shellcheck`. State in the pull request which consumer you used. + +## 2. The root scripts are the published surface + +The scripts in the repository root are the artefact that consumers call. Each +one reads its configuration from `./ci/cfg/`, so it only runs from a consumer +working directory. + +`references.sh` computes the changed-file list for every linter. It also reads +an optional `linting_excluded_files.txt` from the consumer root. Consumers rely +on that behaviour. Never remove it. + +The scripts in `local/` are the developer-machine variants. Change a root +script and its `local/` counterpart in the same commit. + +## 3. No consumer may be assumed + +This repository serves firmware repositories and cloud repositories. Examples: +`opinicus`, `okuda` and `libCharon`. + +1. Never add a rule, a path or a default that assumes a cloud service. +2. Never add a rule, a path or a default that assumes a printer. +3. Keep every path in a runner relative to the consumer root. + +## 4. A change here lands in every consumer + +Land the change in this repository first. Then move each consumer's `ci` +submodule pointer in its own commit. Name the consumers in the pull request. +A change that tightens a linter breaks every consumer that has not fixed its +code yet, so announce it before you merge it. diff --git a/.agents/rules/34-library-consumer-contract-rules.md b/.agents/rules/34-library-consumer-contract-rules.md new file mode 100644 index 0000000..18033ad --- /dev/null +++ b/.agents/rules/34-library-consumer-contract-rules.md @@ -0,0 +1,56 @@ +--- +name: library-consumer-contract +description: This repository's public surface is mounted into other repositories — every exported symbol, model field and schema is a cross-repository contract. +trigger: glob +glob: "**/*.py" +paths: + - "**/*.py" +--- +# Library Consumer Contract + +This repository is consumed by other repositories rather than run on its own. +The evidence, from this tree: + +- sibling checkouts pin this repository as a submodule: `libCharon` at `ci` + + +A service is bounded by its own process: rename an internal function and +nothing outside notices. This repository has no such boundary. Its surface is +mounted into someone else's build, so a change here lands in trees that this +checkout cannot see and that no test in this repository runs. + +## What that makes a breaking change + +Within the published surface, all of the following are contract changes, +not refactors: + +1. **Renaming or removing any exported symbol** — class, function, constant, + type, or module path. A consumer imports it by name. +2. **Changing a model or schema field** — removing it, renaming it, narrowing + its type, or making an optional field required. Adding an optional field + with a default is the only safe shape change. +3. **Changing a default value or an enum member**, including its wire value. +4. **Moving a file between packages**, even with the symbol re-exported: a + consumer may import the module path directly. + +## How to make one anyway + +1. **Name the consumers in the pull request.** + - `libCharon` mounts this repository at `ci`. + +2. **Land this repository first, then move each consumer's pointer.** A + submodule pointer bump is its own commit and names the revision it moves to. + Never commit inside a consumer's mounted copy of this tree. +3. **Additive first.** Where a breaking shape is unavoidable, ship the new + surface alongside the old one, migrate the consumers, and remove the old + surface in a later ticket — not in the same one. +4. **Say so in the commit message.** The consumers' agents read this + repository's history to work out what moved under them. + +## What this rule does not cover + +The *meaning* of the contract — which peer owns which definition, what happens +operationally when a field changes — is the ecosystem-contract investigator's +subject (`.agents/agents/ecosystem_contract_investigator/agent.md`). This rule +covers only what is provable from the tree: that the surface is shared, and +that a change to it is never local. diff --git a/.agents/rules/40-skill-discovery-index-rules.md b/.agents/rules/40-skill-discovery-index-rules.md new file mode 100644 index 0000000..68d6d47 --- /dev/null +++ b/.agents/rules/40-skill-discovery-index-rules.md @@ -0,0 +1,42 @@ +--- +name: skill-discovery-index +description: Full index of UltiCortex catalogue skills no automatic trigger matched — consult when a task needs domain knowledge the matched skills lack. +trigger: model_decision +--- +# UltiCortex Skill Index (unmatched skills) + +These catalogue skills matched no automatic trigger for this repository. By +construction they are the ones static detection judged irrelevant — but static +detection cannot see every reason a skill applies. If the current task touches +one of these domains, install and load the skill before implementing: + +```bash +gh skill install Ultimaker/UltiCortex +``` + +- **cmake** — Modern target-centric C/C++ engineering with CMake 3 & 4. +- **code-best-practices-search** — Executes native GitHub CLI (`gh search code`) searches to retrieve untruncated raw file blobs and evaluate implementations against multi-dimensional architectural constraints (Security, Reliability, Agentic Development, Efficiency,…. +- **conan-2** — Engineering guide for Conan 2 dependency management, cross-compilation, CMake integrations, and packaging workflows. +- **cpp-pro** — Expert modern C++ (C++11 through C++23) engineering grounded in the ISO C++ Core Guidelines. +- **github-search-code** — Search for code across GitHub repositories using the `gh search code` CLI tool. +- **jlink-debugging** — Expert headless debugging, flashing, RTT telemetry, and RAM state injection using SEGGER J-Link probes. +- **python-pro** — Use this agent when you need to build type-safe, production-ready Python code for web APIs, system utilities, or complex applications requiring modern async patterns and extensive type coverage. +- **rigol-ds1054z** — Automated oscilloscope control over USB using PyVISA to capture, analyze, and synchronize electrical signals. +- **sentry-issue-fixer** — Query the Ultimaker Sentry MCP server (ultimaker-o7/griffin) to find the most user-impacting bugs in the S6, S8, F4, and F4+ firmware. +- **ultimaker-atlas** — Bring up stardust-atlas (Digital Factory meta-repo): submodules, docker compose, DB seeding, and its MCP server. +- **ultimaker-cloud-development** — Cross-repository guide to the UltiMaker Digital Factory cloud: the stardust constellation, GCP platform, environments and deployment, and the shared backend/frontend patterns. +- **ultimaker-cura-development** — Comprehensive guide to working with Ultimaker Cura application architecture, Uranium plugins, container stacks, fdm_materials profiles, and cloud gateway integrations. +- **ultimaker-curator-development** — Comprehensive guide to working with the Curator C++ configuration engine, the setting stack resolver, fdmprinter specifications, and WebAssembly bindings. +- **ultimaker-digital-factory** — Comprehensive skill for interacting with the UltiMaker Digital Factory (DF) API. +- **ultimaker-firmware-development** — Cross-repository guide to UltiMaker printer firmware: product lines, the repo constellation, the jedi build/release pipeline, and interface discipline. +- **ultimaker-gcode-architecture** — Canonical Marlin & UltiMaker custom G/M-code registry, dispatch map, and ecosystem synchronization protocol. +- **ultimaker-material-knowledge** — Plastics engineering calculations for extrusion, viscosity models, thermal properties, and polymer processing. +- **ultimaker-neoprep-development** — Comprehensive guide to developing, building, testing, and operating the Neoprep React applications, including integration with Curator and CuraEngine WASM modules. +- **ultimaker-printer-logs** — Diagnostic log parser for Ultimaker 3D printers, diagnosing reboots, OOMs, and mechanical anomalies. +- **ultimaker-printer-ssh** — SSH debugging, systemd-dbus, and hardware mocking on S-line, Next-Gen, and Factor 4(+) printers. +- **ultimaker-retrospective-engineering** — Conduct retrospective post-mortems for AI agent/developer incidents and translate takeaways into automated tool hooks and CI guardrails. +- **ultimaker-sentry** — Query and triage Ultimaker firmware crashes in Sentry (ultimaker-o7) via MCP tools and REST API, covering project topology, device tag filters, and machine-level event lookup. +- **ultimaker-support-articles** — Use this skill when you need to find articles on the UltiMaker or MakerBot support pages, extract knowledge from public-facing documentation, download resources from support articles, or verify if a support article is up-to-date with…. +- **ultimaker-translation** — Automates 3D printing software localization by reordering PO/POT files alphabetically, performing GraphRAG translations mapped to a local Neo4j dump, and running language-specific reviewer audits to generate clean dual Pull Requests. +- **ultimaker-virtual-printer-fleet** — Tools and instructions for AI agents to monitor and command a fleet of virtual UltiMaker printers. +- **web-accessibility-standard** — WCAG 2.1 AA checklist, keyboard focus rings, semantic HTML, and WebGL/Form accessibility standards. diff --git a/.claude/agents/adversarial_pr_reviewer.md b/.claude/agents/adversarial_pr_reviewer.md new file mode 120000 index 0000000..a312a9f --- /dev/null +++ b/.claude/agents/adversarial_pr_reviewer.md @@ -0,0 +1 @@ +../../.agents/agents/adversarial_pr_reviewer/agent.md \ No newline at end of file diff --git a/.claude/agents/architecture_investigator.md b/.claude/agents/architecture_investigator.md new file mode 120000 index 0000000..cd57811 --- /dev/null +++ b/.claude/agents/architecture_investigator.md @@ -0,0 +1 @@ +../../.agents/agents/architecture_investigator/agent.md \ No newline at end of file diff --git a/.claude/agents/ecosystem_contract_investigator.md b/.claude/agents/ecosystem_contract_investigator.md new file mode 120000 index 0000000..bda1228 --- /dev/null +++ b/.claude/agents/ecosystem_contract_investigator.md @@ -0,0 +1 @@ +../../.agents/agents/ecosystem_contract_investigator/agent.md \ No newline at end of file diff --git a/.claude/rules/01-jira-commit-standards.md b/.claude/rules/01-jira-commit-standards.md new file mode 120000 index 0000000..fa79a2f --- /dev/null +++ b/.claude/rules/01-jira-commit-standards.md @@ -0,0 +1 @@ +../../.agents/rules/01-jira-commit-standards.md \ No newline at end of file diff --git a/.claude/rules/02-security-and-paths.md b/.claude/rules/02-security-and-paths.md new file mode 120000 index 0000000..aea02c0 --- /dev/null +++ b/.claude/rules/02-security-and-paths.md @@ -0,0 +1 @@ +../../.agents/rules/02-security-and-paths.md \ No newline at end of file diff --git a/.claude/rules/04-build-test-and-deployment-rules.md b/.claude/rules/04-build-test-and-deployment-rules.md new file mode 120000 index 0000000..8e271d2 --- /dev/null +++ b/.claude/rules/04-build-test-and-deployment-rules.md @@ -0,0 +1 @@ +../../.agents/rules/04-build-test-and-deployment-rules.md \ No newline at end of file diff --git a/.claude/rules/05-ultimaker-skill-discovery-rules.md b/.claude/rules/05-ultimaker-skill-discovery-rules.md new file mode 120000 index 0000000..7df1ffa --- /dev/null +++ b/.claude/rules/05-ultimaker-skill-discovery-rules.md @@ -0,0 +1 @@ +../../.agents/rules/05-ultimaker-skill-discovery-rules.md \ No newline at end of file diff --git a/.claude/rules/06-pull-request-lifecycle-rules.md b/.claude/rules/06-pull-request-lifecycle-rules.md new file mode 120000 index 0000000..4c70f19 --- /dev/null +++ b/.claude/rules/06-pull-request-lifecycle-rules.md @@ -0,0 +1 @@ +../../.agents/rules/06-pull-request-lifecycle-rules.md \ No newline at end of file diff --git a/.claude/rules/07-owasp-security-rules.md b/.claude/rules/07-owasp-security-rules.md new file mode 120000 index 0000000..ff443c2 --- /dev/null +++ b/.claude/rules/07-owasp-security-rules.md @@ -0,0 +1 @@ +../../.agents/rules/07-owasp-security-rules.md \ No newline at end of file diff --git a/.claude/rules/08-scoped-changes-and-minimal-diffs.md b/.claude/rules/08-scoped-changes-and-minimal-diffs.md new file mode 120000 index 0000000..cd9a88f --- /dev/null +++ b/.claude/rules/08-scoped-changes-and-minimal-diffs.md @@ -0,0 +1 @@ +../../.agents/rules/08-scoped-changes-and-minimal-diffs.md \ No newline at end of file diff --git a/.claude/rules/09-atomic-bisect-safe-commits.md b/.claude/rules/09-atomic-bisect-safe-commits.md new file mode 120000 index 0000000..2dad32d --- /dev/null +++ b/.claude/rules/09-atomic-bisect-safe-commits.md @@ -0,0 +1 @@ +../../.agents/rules/09-atomic-bisect-safe-commits.md \ No newline at end of file diff --git a/.claude/rules/10-file-size-and-decomposition-rules.md b/.claude/rules/10-file-size-and-decomposition-rules.md new file mode 120000 index 0000000..ccc2705 --- /dev/null +++ b/.claude/rules/10-file-size-and-decomposition-rules.md @@ -0,0 +1 @@ +../../.agents/rules/10-file-size-and-decomposition-rules.md \ No newline at end of file diff --git a/.claude/rules/12-ai-context-exclusion-rules.md b/.claude/rules/12-ai-context-exclusion-rules.md new file mode 120000 index 0000000..d3be6d7 --- /dev/null +++ b/.claude/rules/12-ai-context-exclusion-rules.md @@ -0,0 +1 @@ +../../.agents/rules/12-ai-context-exclusion-rules.md \ No newline at end of file diff --git a/.claude/rules/13-dependency-management-rules.md b/.claude/rules/13-dependency-management-rules.md new file mode 120000 index 0000000..b0d9bfe --- /dev/null +++ b/.claude/rules/13-dependency-management-rules.md @@ -0,0 +1 @@ +../../.agents/rules/13-dependency-management-rules.md \ No newline at end of file diff --git a/.claude/rules/15-shipped-runner-and-consumer-rules.md b/.claude/rules/15-shipped-runner-and-consumer-rules.md new file mode 120000 index 0000000..56a7b2d --- /dev/null +++ b/.claude/rules/15-shipped-runner-and-consumer-rules.md @@ -0,0 +1 @@ +../../.agents/rules/15-shipped-runner-and-consumer-rules.md \ No newline at end of file diff --git a/.claude/rules/34-library-consumer-contract-rules.md b/.claude/rules/34-library-consumer-contract-rules.md new file mode 120000 index 0000000..8bc4745 --- /dev/null +++ b/.claude/rules/34-library-consumer-contract-rules.md @@ -0,0 +1 @@ +../../.agents/rules/34-library-consumer-contract-rules.md \ No newline at end of file diff --git a/.claude/rules/40-skill-discovery-index-rules.md b/.claude/rules/40-skill-discovery-index-rules.md new file mode 120000 index 0000000..107267d --- /dev/null +++ b/.claude/rules/40-skill-discovery-index-rules.md @@ -0,0 +1 @@ +../../.agents/rules/40-skill-discovery-index-rules.md \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..92cd1c2 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,89 @@ +{ + "claudeMdExcludes": [ + "**/vendor/**", + "**/third_party/**" + ], + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|Write|Edit|MultiEdit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "python3 .agents/hooks/pretool_guard.py" + }, + { + "type": "command", + "command": "python3 .agents/hooks/block_name_matched_kill.py" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "bash .agents/hooks/post-edit-linter.sh" + } + ] + } + ] + }, + "permissions": { + "deny": [ + "Read(./**/*.pem)", + "Read(./**/*.key)", + "Read(./**/*.p12)", + "Read(./**/*.pfx)", + "Read(./**/*.jks)", + "Read(./**/*.keystore)", + "Read(./**/id_rsa)", + "Read(./**/id_ecdsa)", + "Read(./**/id_ed25519)", + "Read(./**/*.gpg)", + "Read(./**/*.asc)", + "Read(./**/.env)", + "Read(./**/.env.*)", + "Read(./**/.env-*)", + "Read(./**/.env_*)", + "Read(./**/*.tfvars)", + "Read(./**/.netrc)", + "Read(./**/.npmrc)", + "Read(./**/credentials.json)", + "Read(./**/service-account*.json)", + "Read(./**/secrets/**)", + "Read(./**/vendor/**)", + "Read(./**/third_party/**)", + "Read(./**/node_modules/**)", + "Read(./**/build/**)", + "Read(./**/dist/**)", + "Read(./**/out/**)", + "Read(./**/target/**)", + "Read(./**/.venv/**)", + "Read(./**/venv/**)", + "Read(./**/__pycache__/**)", + "Read(./**/*.o)", + "Read(./**/*.a)", + "Read(./**/*.so)", + "Read(./**/*.dylib)", + "Read(./**/*.dll)", + "Read(./**/*.exe)", + "Read(./**/.mypy_cache/**)", + "Read(./**/.pytest_cache/**)", + "Read(./**/.ruff_cache/**)", + "Read(./**/*.stl)", + "Read(./**/*.3mf)", + "Read(./**/*.gcode)", + "Read(./**/*.ufp)", + "Read(./**/*.obj)", + "Read(./**/*.step)", + "Read(./**/*.stp)", + "Read(./**/*.log)", + "Read(./**/logdump*/**)", + "Read(./**/customer-data/**)", + "Read(./**/pii/**)" + ] + } +} diff --git a/.github/agents/adversarial_pr_reviewer.md b/.github/agents/adversarial_pr_reviewer.md new file mode 120000 index 0000000..a312a9f --- /dev/null +++ b/.github/agents/adversarial_pr_reviewer.md @@ -0,0 +1 @@ +../../.agents/agents/adversarial_pr_reviewer/agent.md \ No newline at end of file diff --git a/.github/agents/architecture_investigator.md b/.github/agents/architecture_investigator.md new file mode 120000 index 0000000..cd57811 --- /dev/null +++ b/.github/agents/architecture_investigator.md @@ -0,0 +1 @@ +../../.agents/agents/architecture_investigator/agent.md \ No newline at end of file diff --git a/.github/agents/ecosystem_contract_investigator.md b/.github/agents/ecosystem_contract_investigator.md new file mode 120000 index 0000000..bda1228 --- /dev/null +++ b/.github/agents/ecosystem_contract_investigator.md @@ -0,0 +1 @@ +../../.agents/agents/ecosystem_contract_investigator/agent.md \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..5f59063 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,39 @@ + + +# GitHub Copilot Repository Instructions + +This repository uses a Quad-Agent setup. The single source of truth for agent +behavior is [AGENTS.md](../AGENTS.md) at the repository root, plus the numbered +rule files in [.agents/rules/](../.agents/rules/). + +Apply, in order: + +1. `AGENTS.md` — operational guide, tech stack, directory layout, PR flow. +2. The numbered rules below (01–14 always apply; 20+ apply to matching files). +3. `DESIGN.md` (if present) — design tokens for any UI work. + +## Rule Index + +- [01-jira-commit-standards](../.agents/rules/01-jira-commit-standards.md) +- [02-security-and-paths](../.agents/rules/02-security-and-paths.md) +- [04-build-test-and-deployment](../.agents/rules/04-build-test-and-deployment-rules.md) +- [05-ultimaker-skill-discovery](../.agents/rules/05-ultimaker-skill-discovery-rules.md) +- [06-pull-request-lifecycle](../.agents/rules/06-pull-request-lifecycle-rules.md) +- [07-owasp-security](../.agents/rules/07-owasp-security-rules.md) +- [08-scoped-changes-and-minimal-diffs](../.agents/rules/08-scoped-changes-and-minimal-diffs.md) +- [09-atomic-bisect-safe-commits](../.agents/rules/09-atomic-bisect-safe-commits.md) +- [10-file-size-and-decomposition](../.agents/rules/10-file-size-and-decomposition-rules.md) +- [12-ai-context-exclusion](../.agents/rules/12-ai-context-exclusion-rules.md) +- [13-dependency-management](../.agents/rules/13-dependency-management-rules.md) +- [15-shipped-runner-and-consumer](../.agents/rules/15-shipped-runner-and-consumer-rules.md) +- [34-library-consumer-contract](../.agents/rules/34-library-consumer-contract-rules.md) +- [40-skill-discovery-index](../.agents/rules/40-skill-discovery-index-rules.md) + + +Hard constraints (mirrored in hooks under `.github/hooks/copilot-hooks.json`): + +- Commit titles start with a bracketed Jira key (`[KEY-123] Title`); no + semantic prefixes (`feat:`, `fix:`). +- Never commit to `main`/`master`/`staging`; PRs open as DRAFT; humans merge. +- No secrets, no absolute local paths, no vendor/third-party edits. diff --git a/.github/hooks/copilot-hooks.json b/.github/hooks/copilot-hooks.json new file mode 100644 index 0000000..11e245b --- /dev/null +++ b/.github/hooks/copilot-hooks.json @@ -0,0 +1,33 @@ +{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "type": "command", + "bash": "python3 .agents/hooks/pretool_guard.py", + "timeoutSec": 15 + }, + + { + "type": "command", + "bash": "python3 .agents/hooks/block_name_matched_kill.py", + "timeoutSec": 10 + }, + + { + "type": "command", + "bash": "python3 .agents/hooks/suggest-skills.py --pre-edit", + "timeoutSec": 15 + } + + ], + "postToolUse": [ + { + "type": "command", + "bash": "bash .agents/hooks/post-edit-linter.sh", + "timeoutSec": 60 + } + + ] + } +} diff --git a/.opencode/agents/adversarial_pr_reviewer.md b/.opencode/agents/adversarial_pr_reviewer.md new file mode 120000 index 0000000..a312a9f --- /dev/null +++ b/.opencode/agents/adversarial_pr_reviewer.md @@ -0,0 +1 @@ +../../.agents/agents/adversarial_pr_reviewer/agent.md \ No newline at end of file diff --git a/.opencode/agents/architecture_investigator.md b/.opencode/agents/architecture_investigator.md new file mode 120000 index 0000000..cd57811 --- /dev/null +++ b/.opencode/agents/architecture_investigator.md @@ -0,0 +1 @@ +../../.agents/agents/architecture_investigator/agent.md \ No newline at end of file diff --git a/.opencode/agents/ecosystem_contract_investigator.md b/.opencode/agents/ecosystem_contract_investigator.md new file mode 120000 index 0000000..bda1228 --- /dev/null +++ b/.opencode/agents/ecosystem_contract_investigator.md @@ -0,0 +1 @@ +../../.agents/agents/ecosystem_contract_investigator/agent.md \ No newline at end of file diff --git a/.opencode/rules/01-jira-commit-standards.md b/.opencode/rules/01-jira-commit-standards.md new file mode 120000 index 0000000..fa79a2f --- /dev/null +++ b/.opencode/rules/01-jira-commit-standards.md @@ -0,0 +1 @@ +../../.agents/rules/01-jira-commit-standards.md \ No newline at end of file diff --git a/.opencode/rules/02-security-and-paths.md b/.opencode/rules/02-security-and-paths.md new file mode 120000 index 0000000..aea02c0 --- /dev/null +++ b/.opencode/rules/02-security-and-paths.md @@ -0,0 +1 @@ +../../.agents/rules/02-security-and-paths.md \ No newline at end of file diff --git a/.opencode/rules/04-build-test-and-deployment-rules.md b/.opencode/rules/04-build-test-and-deployment-rules.md new file mode 120000 index 0000000..8e271d2 --- /dev/null +++ b/.opencode/rules/04-build-test-and-deployment-rules.md @@ -0,0 +1 @@ +../../.agents/rules/04-build-test-and-deployment-rules.md \ No newline at end of file diff --git a/.opencode/rules/05-ultimaker-skill-discovery-rules.md b/.opencode/rules/05-ultimaker-skill-discovery-rules.md new file mode 120000 index 0000000..7df1ffa --- /dev/null +++ b/.opencode/rules/05-ultimaker-skill-discovery-rules.md @@ -0,0 +1 @@ +../../.agents/rules/05-ultimaker-skill-discovery-rules.md \ No newline at end of file diff --git a/.opencode/rules/06-pull-request-lifecycle-rules.md b/.opencode/rules/06-pull-request-lifecycle-rules.md new file mode 120000 index 0000000..4c70f19 --- /dev/null +++ b/.opencode/rules/06-pull-request-lifecycle-rules.md @@ -0,0 +1 @@ +../../.agents/rules/06-pull-request-lifecycle-rules.md \ No newline at end of file diff --git a/.opencode/rules/07-owasp-security-rules.md b/.opencode/rules/07-owasp-security-rules.md new file mode 120000 index 0000000..ff443c2 --- /dev/null +++ b/.opencode/rules/07-owasp-security-rules.md @@ -0,0 +1 @@ +../../.agents/rules/07-owasp-security-rules.md \ No newline at end of file diff --git a/.opencode/rules/08-scoped-changes-and-minimal-diffs.md b/.opencode/rules/08-scoped-changes-and-minimal-diffs.md new file mode 120000 index 0000000..cd9a88f --- /dev/null +++ b/.opencode/rules/08-scoped-changes-and-minimal-diffs.md @@ -0,0 +1 @@ +../../.agents/rules/08-scoped-changes-and-minimal-diffs.md \ No newline at end of file diff --git a/.opencode/rules/09-atomic-bisect-safe-commits.md b/.opencode/rules/09-atomic-bisect-safe-commits.md new file mode 120000 index 0000000..2dad32d --- /dev/null +++ b/.opencode/rules/09-atomic-bisect-safe-commits.md @@ -0,0 +1 @@ +../../.agents/rules/09-atomic-bisect-safe-commits.md \ No newline at end of file diff --git a/.opencode/rules/10-file-size-and-decomposition-rules.md b/.opencode/rules/10-file-size-and-decomposition-rules.md new file mode 120000 index 0000000..ccc2705 --- /dev/null +++ b/.opencode/rules/10-file-size-and-decomposition-rules.md @@ -0,0 +1 @@ +../../.agents/rules/10-file-size-and-decomposition-rules.md \ No newline at end of file diff --git a/.opencode/rules/12-ai-context-exclusion-rules.md b/.opencode/rules/12-ai-context-exclusion-rules.md new file mode 120000 index 0000000..d3be6d7 --- /dev/null +++ b/.opencode/rules/12-ai-context-exclusion-rules.md @@ -0,0 +1 @@ +../../.agents/rules/12-ai-context-exclusion-rules.md \ No newline at end of file diff --git a/.opencode/rules/13-dependency-management-rules.md b/.opencode/rules/13-dependency-management-rules.md new file mode 120000 index 0000000..b0d9bfe --- /dev/null +++ b/.opencode/rules/13-dependency-management-rules.md @@ -0,0 +1 @@ +../../.agents/rules/13-dependency-management-rules.md \ No newline at end of file diff --git a/.opencode/rules/15-shipped-runner-and-consumer-rules.md b/.opencode/rules/15-shipped-runner-and-consumer-rules.md new file mode 120000 index 0000000..56a7b2d --- /dev/null +++ b/.opencode/rules/15-shipped-runner-and-consumer-rules.md @@ -0,0 +1 @@ +../../.agents/rules/15-shipped-runner-and-consumer-rules.md \ No newline at end of file diff --git a/.opencode/rules/34-library-consumer-contract-rules.md b/.opencode/rules/34-library-consumer-contract-rules.md new file mode 120000 index 0000000..8bc4745 --- /dev/null +++ b/.opencode/rules/34-library-consumer-contract-rules.md @@ -0,0 +1 @@ +../../.agents/rules/34-library-consumer-contract-rules.md \ No newline at end of file diff --git a/.opencode/rules/40-skill-discovery-index-rules.md b/.opencode/rules/40-skill-discovery-index-rules.md new file mode 120000 index 0000000..107267d --- /dev/null +++ b/.opencode/rules/40-skill-discovery-index-rules.md @@ -0,0 +1 @@ +../../.agents/rules/40-skill-discovery-index-rules.md \ No newline at end of file diff --git a/.opencode/rules/agents.md b/.opencode/rules/agents.md new file mode 120000 index 0000000..b7e6491 --- /dev/null +++ b/.opencode/rules/agents.md @@ -0,0 +1 @@ +../../AGENTS.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0e8d050 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,103 @@ + + +# AGENTS.md — Orientation for AI Agents + +> **What this file is:** everything an agent needs to understand *what this +> repository is and how to work in it*. It is orientation, not policy. +> +> **What this file is NOT:** it does not contain rules. Normative constraints — +> what you MUST and MUST NOT do — live in `.agents/rules/` and are enforced by +> hooks. Never restate a rule here; a duplicated rule drifts from the original +> and agents then follow the stale copy. See *Where Everything Lives* at the end. + +--- + +## 1. What This Repository Is + +**Repository:** `python-quality-control` +**Ecosystem context:** UltiMaker Ecosystem + + +## 2. Tech Stack + +- **Tooling** — mypy +- **Tests** — pytest + + +## 3. Directory Map + +| Directory | Files | Predominant types | +|---|---|---| +| `cfg/` | 11 | .ini, .txt, .TAG | +| `local/` | 9 | .sh | +| `scripts/` | 2 | .py | + + +## 4. Local Development + +### Test + +```bash +pytest -x -q cfg +./run_pytest.sh +``` +### Continuous Integration + +CI runs on github-actions and covers tests, lint. Reproduce a CI failure locally before pushing a fix. + + +## 5. Where Everything Lives + +Each fact belongs in exactly one place. When they disagree, the more specific +one wins — and the disagreement is a bug worth fixing. + +| Layer | Answers | Location | +|---|---|---| +| **Orientation** | What is this, how do I work in it? | this file | +| **Rules** | What must I do, what must I never do? | `.agents/rules/*.md` (symlinked into `.claude/rules/`, `.opencode/rules/`) | +| **Mechanical enforcement** | What does the tooling refuse, regardless of intent? | `.agents/hooks/*` and `.pre-commit-config.yaml` | +| **AI exclusion** | What must never be read by a model? | `.aiignore` (compiled into each platform's mechanism) | +| **What was inferred** | Why is this configured the way it is? | `.agents/bootstrap-profile.json` | +| **Open proposals** | What might still become a rule? | `.agents/bootstrap-observations.md` | + +Rule numbers encode the LOAD TIER and the OWNER, not the age of the rule: + +| Band | Load tier | Owner | +|---|---|---| +| `01`–`14` | always on | managed — regenerated by the bootstrap | +| `15`–`19` | always on | this repository — never touched by the bootstrap | +| `20`–`34` | glob-scoped (`paths:`) | managed — regenerated by the bootstrap | +| `35`–`39` | glob-scoped (`paths:`) | this repository | +| `40`–`44` | model decision | managed — regenerated by the bootstrap | +| `45`–`59` | model decision | this repository | + +`compile_rule_frontmatter.py` enforces these bands. Put a repository-specific +rule in a repo-owned band; one left in a managed band is renumbered into the +matching custom band on the next run. + + +### Skills to load + +This repository is covered by the following UltiCortex skills. Load the relevant +one before designing or implementing — `.agents/rules/05-*` explains when each +applies. + +- `software-architect` + + + +--- + +## Still to be written + +The bootstrap fills only what it can prove from the repository. These sections +need a human or an investigating agent, and are deliberately absent rather than +present as empty stubs: + +- **What this repository is — purpose and business value** +- **Position in the wider system — what it consumes and what consumes it** +- **How to build** +- **Architecture and domain concepts — the abstractions, the vocabulary, and the invariants no detector can infer** +- **Verification and validation — how an agent knows a change actually works here** diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..f07e436 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,106 @@ + + + + +# AGENTS.md — Orientation for AI Agents + +> **What this file is:** everything an agent needs to understand *what this +> repository is and how to work in it*. It is orientation, not policy. +> +> **What this file is NOT:** it does not contain rules. Normative constraints — +> what you MUST and MUST NOT do — live in `.agents/rules/` and are enforced by +> hooks. Never restate a rule here; a duplicated rule drifts from the original +> and agents then follow the stale copy. See *Where Everything Lives* at the end. + +--- + +## 1. What This Repository Is + +**Repository:** `python-quality-control` +**Ecosystem context:** UltiMaker Ecosystem + + +## 2. Tech Stack + +- **Tooling** — mypy +- **Tests** — pytest + + +## 3. Directory Map + +| Directory | Files | Predominant types | +|---|---|---| +| `cfg/` | 11 | .ini, .txt, .TAG | +| `local/` | 9 | .sh | +| `scripts/` | 2 | .py | + + +## 4. Local Development + +### Test + +```bash +pytest -x -q cfg +./run_pytest.sh +``` +### Continuous Integration + +CI runs on github-actions and covers tests, lint. Reproduce a CI failure locally before pushing a fix. + + +## 5. Where Everything Lives + +Each fact belongs in exactly one place. When they disagree, the more specific +one wins — and the disagreement is a bug worth fixing. + +| Layer | Answers | Location | +|---|---|---| +| **Orientation** | What is this, how do I work in it? | this file | +| **Rules** | What must I do, what must I never do? | `.agents/rules/*.md` (symlinked into `.claude/rules/`, `.opencode/rules/`) | +| **Mechanical enforcement** | What does the tooling refuse, regardless of intent? | `.agents/hooks/*` and `.pre-commit-config.yaml` | +| **AI exclusion** | What must never be read by a model? | `.aiignore` (compiled into each platform's mechanism) | +| **What was inferred** | Why is this configured the way it is? | `.agents/bootstrap-profile.json` | +| **Open proposals** | What might still become a rule? | `.agents/bootstrap-observations.md` | + +Rule numbers encode the LOAD TIER and the OWNER, not the age of the rule: + +| Band | Load tier | Owner | +|---|---|---| +| `01`–`14` | always on | managed — regenerated by the bootstrap | +| `15`–`19` | always on | this repository — never touched by the bootstrap | +| `20`–`34` | glob-scoped (`paths:`) | managed — regenerated by the bootstrap | +| `35`–`39` | glob-scoped (`paths:`) | this repository | +| `40`–`44` | model decision | managed — regenerated by the bootstrap | +| `45`–`59` | model decision | this repository | + +`compile_rule_frontmatter.py` enforces these bands. Put a repository-specific +rule in a repo-owned band; one left in a managed band is renumbered into the +matching custom band on the next run. + + +### Skills to load + +This repository is covered by the following UltiCortex skills. Load the relevant +one before designing or implementing — `.agents/rules/05-*` explains when each +applies. + +- `software-architect` + + + +--- + +## Still to be written + +The bootstrap fills only what it can prove from the repository. These sections +need a human or an investigating agent, and are deliberately absent rather than +present as empty stubs: + +- **What this repository is — purpose and business value** +- **Position in the wider system — what it consumes and what consumes it** +- **How to build** +- **Architecture and domain concepts — the abstractions, the vocabulary, and the invariants no detector can infer** +- **Verification and validation — how an agent knows a change actually works here** diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..983b281 --- /dev/null +++ b/opencode.json @@ -0,0 +1,172 @@ +{ + "$schema": "https://opencode.ai/config.json", + "instructions": [ + "AGENTS.md", + ".opencode/rules/*.md" + ], + "lsp": false, + "permission": { + "read": { + "**": "allow", + "**/*.pem": "deny", + "**/*.key": "deny", + "**/*.p12": "deny", + "**/*.pfx": "deny", + "**/*.jks": "deny", + "**/*.keystore": "deny", + "**/id_rsa": "deny", + "**/id_ecdsa": "deny", + "**/id_ed25519": "deny", + "**/*.gpg": "deny", + "**/*.asc": "deny", + "**/.env": "deny", + "**/.env.*": "deny", + "**/.env-*": "deny", + "**/.env_*": "deny", + "**/*.tfvars": "deny", + "**/.netrc": "deny", + "**/.npmrc": "deny", + "**/credentials.json": "deny", + "**/service-account*.json": "deny", + "**/secrets/**": "deny", + "**/vendor/**": "deny", + "**/third_party/**": "deny", + "**/node_modules/**": "deny", + "**/build/**": "deny", + "**/dist/**": "deny", + "**/out/**": "deny", + "**/target/**": "deny", + "**/.venv/**": "deny", + "**/venv/**": "deny", + "**/__pycache__/**": "deny", + "**/*.o": "deny", + "**/*.a": "deny", + "**/*.so": "deny", + "**/*.dylib": "deny", + "**/*.dll": "deny", + "**/*.exe": "deny", + "**/.mypy_cache/**": "deny", + "**/.pytest_cache/**": "deny", + "**/.ruff_cache/**": "deny", + "**/*.stl": "deny", + "**/*.3mf": "deny", + "**/*.gcode": "deny", + "**/*.ufp": "deny", + "**/*.obj": "deny", + "**/*.step": "deny", + "**/*.stp": "deny", + "**/*.log": "deny", + "**/logdump*/**": "deny", + "**/customer-data/**": "deny", + "**/pii/**": "deny" + }, + "glob": { + "**": "allow", + "**/*.pem": "deny", + "**/*.key": "deny", + "**/*.p12": "deny", + "**/*.pfx": "deny", + "**/*.jks": "deny", + "**/*.keystore": "deny", + "**/id_rsa": "deny", + "**/id_ecdsa": "deny", + "**/id_ed25519": "deny", + "**/*.gpg": "deny", + "**/*.asc": "deny", + "**/.env": "deny", + "**/.env.*": "deny", + "**/.env-*": "deny", + "**/.env_*": "deny", + "**/*.tfvars": "deny", + "**/.netrc": "deny", + "**/.npmrc": "deny", + "**/credentials.json": "deny", + "**/service-account*.json": "deny", + "**/secrets/**": "deny", + "**/vendor/**": "deny", + "**/third_party/**": "deny", + "**/node_modules/**": "deny", + "**/build/**": "deny", + "**/dist/**": "deny", + "**/out/**": "deny", + "**/target/**": "deny", + "**/.venv/**": "deny", + "**/venv/**": "deny", + "**/__pycache__/**": "deny", + "**/*.o": "deny", + "**/*.a": "deny", + "**/*.so": "deny", + "**/*.dylib": "deny", + "**/*.dll": "deny", + "**/*.exe": "deny", + "**/.mypy_cache/**": "deny", + "**/.pytest_cache/**": "deny", + "**/.ruff_cache/**": "deny", + "**/*.stl": "deny", + "**/*.3mf": "deny", + "**/*.gcode": "deny", + "**/*.ufp": "deny", + "**/*.obj": "deny", + "**/*.step": "deny", + "**/*.stp": "deny", + "**/*.log": "deny", + "**/logdump*/**": "deny", + "**/customer-data/**": "deny", + "**/pii/**": "deny" + }, + "grep": { + "**": "allow", + "**/*.pem": "deny", + "**/*.key": "deny", + "**/*.p12": "deny", + "**/*.pfx": "deny", + "**/*.jks": "deny", + "**/*.keystore": "deny", + "**/id_rsa": "deny", + "**/id_ecdsa": "deny", + "**/id_ed25519": "deny", + "**/*.gpg": "deny", + "**/*.asc": "deny", + "**/.env": "deny", + "**/.env.*": "deny", + "**/.env-*": "deny", + "**/.env_*": "deny", + "**/*.tfvars": "deny", + "**/.netrc": "deny", + "**/.npmrc": "deny", + "**/credentials.json": "deny", + "**/service-account*.json": "deny", + "**/secrets/**": "deny", + "**/vendor/**": "deny", + "**/third_party/**": "deny", + "**/node_modules/**": "deny", + "**/build/**": "deny", + "**/dist/**": "deny", + "**/out/**": "deny", + "**/target/**": "deny", + "**/.venv/**": "deny", + "**/venv/**": "deny", + "**/__pycache__/**": "deny", + "**/*.o": "deny", + "**/*.a": "deny", + "**/*.so": "deny", + "**/*.dylib": "deny", + "**/*.dll": "deny", + "**/*.exe": "deny", + "**/.mypy_cache/**": "deny", + "**/.pytest_cache/**": "deny", + "**/.ruff_cache/**": "deny", + "**/*.stl": "deny", + "**/*.3mf": "deny", + "**/*.gcode": "deny", + "**/*.ufp": "deny", + "**/*.obj": "deny", + "**/*.step": "deny", + "**/*.stp": "deny", + "**/*.log": "deny", + "**/logdump*/**": "deny", + "**/customer-data/**": "deny", + "**/pii/**": "deny" + } + } +} From 0cb5a77ed4249a193cc5843b4538d0c88c0a5fbe Mon Sep 17 00:00:00 2001 From: jellespijker Date: Sat, 15 Aug 2026 14:04:06 +0200 Subject: [PATCH 3/3] [UC-3697] Add pull request template and agent workflow scripts - scripts/verify_and_create_pr.sh runs the gates before a pull request. - scripts/sync_agentic_configs.sh rebuilds the per-harness links. - scripts/get_github_token.sh reads the token from the system keyring. - scripts/create_boyscout_branch_and_pr.sh isolates unrelated fixes. - .github/PULL_REQUEST_TEMPLATE.md sets the required sections. --- .github/PULL_REQUEST_TEMPLATE.md | 21 +++ scripts/create_boyscout_branch_and_pr.sh | 118 ++++++++++++++++ scripts/get_github_token.sh | 50 +++++++ scripts/sync_agentic_configs.sh | 116 ++++++++++++++++ scripts/verify_and_create_pr.sh | 165 +++++++++++++++++++++++ 5 files changed, 470 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100755 scripts/create_boyscout_branch_and_pr.sh create mode 100755 scripts/get_github_token.sh create mode 100755 scripts/sync_agentic_configs.sh create mode 100755 scripts/verify_and_create_pr.sh diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..b91ef47 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,21 @@ +Jira ticket: [UC-123](https://ultimaker.atlassian.net/browse/UC-123) + +## Why + + +## What + + +## How + + +## Verification & Validation (V&V) + +- [ ] Automated unit/integration tests pass +- [ ] Pre-commit static checks pass cleanly on this branch's diff (`scripts/verify_and_create_pr.sh`) +- [ ] Visual evidence attached for UI changes (if applicable) + +## PR Checklist +- [ ] Initiating developer reviewed AI-generated code +- [ ] No hardcoded absolute local paths or secrets introduced +- [ ] Standing file-size and complexity budgets respected diff --git a/scripts/create_boyscout_branch_and_pr.sh b/scripts/create_boyscout_branch_and_pr.sh new file mode 100755 index 0000000..432a3f1 --- /dev/null +++ b/scripts/create_boyscout_branch_and_pr.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# @generated by the agentic bootstrap. Regenerated on every run; the absence +# of this header is how the writer recognises a hand-written file and refuses +# to overwrite it. Edit the bootstrap, not this file. +# create_boyscout_branch_and_pr.sh +# +# Extract an opportunistic fix ("boy scouting") off the current feature branch +# onto its own branch and draft PR, so the primary PR keeps a single intent. +# +# Rule 08 and two generated hooks tell agents to run this script. The generator +# used to emit those references without ever emitting the script, so the advice +# dead-ended at "No such file or directory" — the bootstrap must emit whatever +# it tells an agent to run. +# +# Usage: +# bash scripts/create_boyscout_branch_and_pr.sh ... +# Example: +# bash scripts/create_boyscout_branch_and_pr.sh UC-123 stale-readme README.md + +set -euo pipefail + +if [ "$#" -lt 3 ]; then + echo "Usage: $0 ..." >&2 + echo "Example: $0 UC-123 stale-readme README.md" >&2 + exit 2 +fi + +TICKET="$1"; shift +TOPIC="$1"; shift +PATHS=("$@") + +if ! printf '%s' "$TICKET" | grep -Eq '^[A-Z][A-Z0-9]{1,9}-[0-9]+$'; then + echo "ERROR: '$TICKET' is not a Jira ticket key (e.g. UC-123)." >&2 + exit 2 +fi + +BASE_BRANCH=$(python3 -c \ + 'import json;print(json.load(open(".agents/bootstrap-profile.json")).get("base_branch","master"))' \ + 2>/dev/null || echo "master") +ORIGINAL_BRANCH=$(git rev-parse --abbrev-ref HEAD) +# Hyphens, not underscores: branch names read as `TICKET-topic`. +BRANCH="${TICKET}-${TOPIC}" + +for path in "${PATHS[@]}"; do + if [ ! -e "$path" ]; then + echo "ERROR: '$path' does not exist." >&2 + exit 2 + fi +done + +REPO_ROOT=$(git rev-parse --show-toplevel) +SAVE_DIR=$(mktemp -d) + +# Deliberately not `git stash`. `refs/stash` is a single ref shared by every +# worktree of a clone, and `git stash pop` always pops stash@{0} — which may +# belong to another worktree. A concurrent pop replays, and destroys, work +# from a tree this script never looked at. +echo "==> Saving the boy-scout paths off ${ORIGINAL_BRANCH}" +tar -cf "${SAVE_DIR}/boyscout.tar" -C "${REPO_ROOT}" -- "${PATHS[@]}" + +restore() { + tar -xf "${SAVE_DIR}/boyscout.tar" -C "${REPO_ROOT}" +} + +cleanup() { + echo "==> Restoring the boy-scout paths after failure" >&2 + git checkout -q "${ORIGINAL_BRANCH}" 2>/dev/null || true + restore + rm -rf "${SAVE_DIR}" +} +trap cleanup ERR + +# Clear the paths so the branch switch is clean, without touching any other +# file: tracked paths go back to HEAD, paths git never knew about are removed. +for path in "${PATHS[@]}"; do + if git ls-files --error-unmatch -- "$path" >/dev/null 2>&1; then + git checkout -- "$path" + else + rm -rf -- "$path" + fi +done + +echo "==> Branching ${BRANCH} from origin/${BASE_BRANCH}" +git fetch origin "${BASE_BRANCH}" --quiet +git checkout -q -b "${BRANCH}" "origin/${BASE_BRANCH}" + +restore +rm -rf "${SAVE_DIR}" +trap - ERR +git add -- "${PATHS[@]}" +git commit -q -m "[${TICKET}] ${TOPIC//-/ }" + +echo "==> Pushing ${BRANCH}" +git push -u origin "${BRANCH}" --quiet + +# Draft only: merging is a human decision (rule 06). +gh pr create --draft --base "${BASE_BRANCH}" --head "${BRANCH}" \ + --title "[${TICKET}] ${TOPIC//-/ }" \ + --body "Boy-scouting extraction from \`${ORIGINAL_BRANCH}\`, kept separate so +that branch stays single-intent (see \`.agents/rules/08-scoped-changes-and-minimal-diffs.md\`). + +## Why +Opportunistic fix noticed while working on \`${ORIGINAL_BRANCH}\`. + +## What +Changes limited to: ${PATHS[*]} + +## How +Extracted verbatim; no other files touched. + +## Verification & Validation (V&V) +- [ ] \`bash scripts/verify_and_create_pr.sh\` passes on this branch + +## PR Checklist +- [ ] Initiating developer reviewed AI-generated code" + +echo "==> Returning to ${ORIGINAL_BRANCH}" +git checkout -q "${ORIGINAL_BRANCH}" diff --git a/scripts/get_github_token.sh b/scripts/get_github_token.sh new file mode 100755 index 0000000..9b60458 --- /dev/null +++ b/scripts/get_github_token.sh @@ -0,0 +1,50 @@ +#!/bin/sh +# @generated by the agentic bootstrap. Regenerated on every run; the absence +# of this header is how the writer recognises a hand-written file and refuses +# to overwrite it. Edit the bootstrap, not this file. +# get_github_token.sh — keyring-first GITHUB_TOKEN retrieval (POSIX sh). +# +# Source this from any script that needs a GitHub token with read:packages: +# . scripts/get_github_token.sh +# +# Retrieval chain (first hit wins): +# 1. an already-exported GITHUB_TOKEN +# 2. the system keyring — Linux: secret-tool (libsecret), +# macOS: security (Keychain) +# 3. a gitignored .env / .env.local fallback, with a warning +# +# Storing the token (run ONCE, by a human — never scripted, never echoed +# into history with the token on the command line where avoidable): +# Linux: echo -n "" | secret-tool store --label="$USER-github-token" \ +# service github user "$USER" +# macOS: security add-generic-password -s github -a "$USER" -w +# (prompts for the secret interactively) +# +# The token is never written to disk or logs by this script. + +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v secret-tool >/dev/null 2>&1; then + GITHUB_TOKEN="$(secret-tool lookup service github user "$USER" 2>/dev/null || true)" + elif command -v security >/dev/null 2>&1; then + GITHUB_TOKEN="$(security find-generic-password -s github -a "$USER" -w 2>/dev/null || true)" + fi +fi + +if [ -z "${GITHUB_TOKEN:-}" ]; then + for env_file in .env.local .env; do + if [ -f "$env_file" ]; then + GITHUB_TOKEN="$(sed -n 's/^GITHUB_TOKEN=//p' "$env_file" | head -1 | tr -d '"')" + if [ -n "$GITHUB_TOKEN" ]; then + echo "WARNING: GITHUB_TOKEN read from gitignored $env_file." >&2 + echo " Prefer the system keyring (see scripts/get_github_token.sh)." >&2 + break + fi + fi + done +fi + +if [ -n "${GITHUB_TOKEN:-}" ]; then + export GITHUB_TOKEN +else + echo "WARNING: no GITHUB_TOKEN found (env, keyring, or .env fallback)." >&2 +fi diff --git a/scripts/sync_agentic_configs.sh b/scripts/sync_agentic_configs.sh new file mode 100755 index 0000000..3a9b502 --- /dev/null +++ b/scripts/sync_agentic_configs.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# @generated by the agentic bootstrap. Regenerated on every run; the absence +# of this header is how the writer recognises a hand-written file and refuses +# to overwrite it. Edit the bootstrap, not this file. +set -euo pipefail + +# Ensure target directories exist +mkdir -p .claude/hooks .opencode .agents/hooks .github/hooks .agents/rules .claude/rules .opencode/rules .agents/agents .claude/agents .opencode/agents .github/agents + +# Clean up broken symlinks in rules (portable: BSD find has no -xtype) +for mirror in .claude/rules .opencode/rules; do + for link in "$mirror"/*; do + if [ -L "$link" ] && [ ! -e "$link" ]; then + rm -f "$link" + fi + done +done + +# Claude Code reads hooks only from .claude/settings.json. A parallel +# .claude/hooks.json is never read and only invites drift — remove it. +rm -f .claude/hooks.json + +# CLAUDE.md may be a symlink (Claude Code follows links); GEMINI.md must be a +# REAL generated file — Antigravity skips symlinks, and a whole rollout wave +# shipped no GEMINI.md at all. +if [ -f AGENTS.md ] && [ ! -L AGENTS.md ]; then + if [ ! -e CLAUDE.md ]; then + ln -s AGENTS.md CLAUDE.md + fi + if [ ! -e GEMINI.md ] || [ -L GEMINI.md ] || [ AGENTS.md -nt GEMINI.md ]; then + rm -f GEMINI.md + { + echo "" + echo "" + cat AGENTS.md + } > GEMINI.md + fi +fi + +# Mirror .agents/rules into .claude/rules and .opencode/rules by EXPECTED-SET +# diff: symlink what should exist, then delete anything else — stale plain +# files included, not just broken symlinks. Renumbering is thereby atomic +# with resync; orphaned duplicate rule copies cannot survive. +if [ -d .agents/rules ]; then + for rulefile in .agents/rules/*.md; do + if [ -f "$rulefile" ]; then + base="$(basename "$rulefile")" + ln -sf "../../.agents/rules/$base" ".claude/rules/$base" 2>/dev/null || true + ln -sf "../../.agents/rules/$base" ".opencode/rules/$base" 2>/dev/null || true + fi + done + for mirror in .claude/rules .opencode/rules; do + for mirrored in "$mirror"/*.md; do + [ -e "$mirrored" ] || [ -L "$mirrored" ] || continue + base="$(basename "$mirrored")" + if [ "$base" = "agents.md" ]; then + continue # opencode's AGENTS.md pointer, managed below + fi + if [ ! -f ".agents/rules/$base" ]; then + rm -f "$mirrored" + fi + done + done +fi + +# Symlink subagent definitions across platforms. Every charter under +# .agents/agents/ is mirrored — naming them individually is how the reviewer +# stayed the only agent the other three platforms could see. +for agent_dir in .agents/agents/*/; do + [ -f "${agent_dir}agent.md" ] || continue + agent_name=$(basename "$agent_dir") + src="../../.agents/agents/${agent_name}/agent.md" + ln -sf "$src" ".claude/agents/${agent_name}.md" 2>/dev/null || true + ln -sf "$src" ".opencode/agents/${agent_name}.md" 2>/dev/null || true + ln -sf "$src" ".github/agents/${agent_name}.md" 2>/dev/null || true +done + +# Symlink AGENTS.md for opencode rules +if [ -f AGENTS.md ]; then + ln -sf "../../AGENTS.md" ".opencode/rules/agents.md" 2>/dev/null || true +fi + +# Register the .talismanrc union merge driver for this clone. Best-effort: +# clones without it merge .talismanrc textually (rare after the threshold +# change shrank the churn surface). GitHub's server-side "mergeable" badge +# cannot see a repo-local driver — a CONFLICTING badge there is cosmetic. +if [ -f .agents/hooks/talisman_guard.py ]; then + git config merge.talismanrc.driver "python3 .agents/hooks/talisman_guard.py merge %O %A %B" 2>/dev/null || true +fi + +# Rebuild the Copilot rule index from .agents/rules/. Copilot learns which +# rules exist from that one link list; the bootstrap renders it from the rules +# a single run produced, so repo-owned and custom-band rules never reached it +# and no sync ever put them back. +if [ -d .agents/rules ] && [ -f .agents/hooks/copilot_index.py ]; then + python3 .agents/hooks/copilot_index.py || true +fi + +# Recompile AI exclusion targets from .aiignore (no platform reads it directly) +if [ -f .aiignore ] && [ -f .agents/hooks/compile_aiignore.py ]; then + python3 .agents/hooks/compile_aiignore.py || true +fi + +# Re-checksum drifted .talismanrc entries LAST, once every file this script can +# still rewrite has reached its final content. Adding a repo-owned rule changes +# .agents/rules-manifest.json, and without this the stale exemption blocked the +# next commit through the sanctioned tool. `refresh` never adds an entry, so a +# genuinely new finding still needs `allow` and a human. +if [ -f .agents/hooks/talisman_guard.py ] && [ -f .talismanrc ] \ + && command -v talisman >/dev/null 2>&1; then + python3 .agents/hooks/talisman_guard.py refresh \ + || echo "WARNING: .talismanrc refresh incomplete (see above)." >&2 +fi + +echo "Synced Quad-Agent configurations and rule structures successfully." diff --git a/scripts/verify_and_create_pr.sh b/scripts/verify_and_create_pr.sh new file mode 100755 index 0000000..8ca4ab4 --- /dev/null +++ b/scripts/verify_and_create_pr.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# @generated by the agentic bootstrap. Regenerated on every run; the absence +# of this header is how the writer recognises a hand-written file and refuses +# to overwrite it. Edit the bootstrap, not this file. +set -euo pipefail + +echo "==> Running Pre-PR Verification & Quality Gate Audit..." +if command -v pre-commit >/dev/null 2>&1; then + # Verify THIS branch's diff, never the whole tree. On a brownfield + # repository `--all-files` makes the whitespace/EOF fixers rewrite hundreds + # of untouched files, and that churn then lands in the PR — the exact + # failure `.agents/dispatch-invariants.md` forbids. + BASE_BRANCH=$(python3 -c 'import json;print(json.load(open(".agents/bootstrap-profile.json")).get("base_branch","main"))' 2>/dev/null || echo main) + BASE_REF="${BASE_REF:-origin/$BASE_BRANCH}" + + # Belt and braces for the scoping above. The fixers are restricted to + # managed paths in `.pre-commit-config.yaml`, but this repository's own + # config is user-editable and a hook added later may well rewrite files. + # So: record the tree, run, and compare. Anything the RUN modified outside + # the branch's own diff is churn, and churn is reverted here rather than + # discovered by a reviewer counting 190 reformatted files. + _pre_state=$(git status --porcelain 2>/dev/null || true) + + _precommit_rc=0 + if git rev-parse --verify --quiet "$BASE_REF" >/dev/null; then + pre-commit run --from-ref "$BASE_REF" --to-ref HEAD || _precommit_rc=$? + else + echo "⚠️ $BASE_REF not found — falling back to staged files only." + pre-commit run || _precommit_rc=$? + fi + + _post_state=$(git status --porcelain 2>/dev/null || true) + if [ "$_pre_state" != "$_post_state" ]; then + # Which files the run itself touched, minus the ones this branch was + # already changing (a fixer tidying your own edit is legitimate). + _branch_files=$(git diff --name-only "$BASE_REF"...HEAD 2>/dev/null || true) + _touched=$(comm -13 \ + <(printf '%s\n' "$_pre_state" | sed 's/^...//' | sort) \ + <(printf '%s\n' "$_post_state" | sed 's/^...//' | sort) || true) + _churn=$(comm -23 \ + <(printf '%s\n' "$_touched" | grep -v '^$' | sort -u) \ + <(printf '%s\n' "$_branch_files" | grep -v '^$' | sort -u) || true) + if [ -n "$_churn" ]; then + echo "❌ pre-commit REWROTE files this branch does not change:" + printf ' %s\n' $_churn + echo " Reverting them: unrelated formatting churn must never ride" + echo " along in a PR about something else. Fix the hook that did" + echo " it (scope it with 'files:'), do not commit the churn." + # shellcheck disable=SC2086 + git checkout -- $_churn 2>/dev/null || true + exit 1 + fi + fi + [ "$_precommit_rc" -eq 0 ] || { echo "❌ Pre-commit checks failed!"; exit 1; } +else + echo "❌ ERROR: pre-commit is not installed! Install it with 'pip install pre-commit'." + exit 1 +fi + +echo "==> Checking orientation docs are actually filled in..." +# One gate, every placeholder syntax: TODO(agent), raw double-brace tokens, +# TBD, . The old gate grepped only TODO(agent) and shipped a +# DESIGN.md whose unfilled token placeholders it could not see. +PLACEHOLDER_RE='TODO\(agent\)|\{\{[A-Z_]+\}\}| V&V table for the PR body (generated from recorded runs):" + python3 .agents/hooks/verify_hooks_fire.py --vv-table +fi + +# Register the .talismanrc union merge driver for this clone (idempotent). +if [ -f .agents/hooks/talisman_guard.py ]; then + git config merge.talismanrc.driver "python3 .agents/hooks/talisman_guard.py merge %O %A %B" || true +fi + +# Base-branch drift is the single most common source of a PR that opens +# already conflicting. The hook is advisory by design — it cannot know whether +# this repository integrates by merge or by rebase — so surface it here, where +# a PR is about to be opened, rather than leaving it installed and uncalled. +if [ -f .agents/hooks/check_upstream_alignment.py ]; then + python3 .agents/hooks/check_upstream_alignment.py || true +fi + +if [ -f .agents/hooks/check_security_downgrades.py ]; then + python3 .agents/hooks/check_security_downgrades.py || { + echo "❌ Security downgrade detected!"; exit 1; } +fi + +echo "==> Verifying credential and environment file isolation..." +if git status --porcelain | awk '{print $2}' | grep -qE '^\.env$|^\.env\.local$'; then + echo "❌ ERROR: Un-ignored or staged .env/.env.local file detected in git status!" + echo " Credentials must NEVER be staged or committed to git." + exit 1 +fi + +echo "✅ All verification checks passed cleanly!" + +# Locate the PR template. The bootstrap already resolved this — GitHub honours +# a repository-root and a docs/ template as well as .github/, and probing only +# .github/ here pointed agents at a template that was not the team's, exactly +# the shadowing this run's detector exists to prevent. Read the recorded answer +# first so the script and the detector cannot disagree. +PR_TEMPLATE=$(python3 -c \ + 'import json;print(json.load(open(".agents/bootstrap-profile.json"))["pr_template"]["path"])' \ + 2>/dev/null || true) +if [ -z "$PR_TEMPLATE" ] || [ ! -f "$PR_TEMPLATE" ]; then + PR_TEMPLATE="" + for cand in .github/PULL_REQUEST_TEMPLATE.md .github/pull_request_template.md \ + .github/workflows/PULL_REQUEST_TEMPLATE.md \ + PULL_REQUEST_TEMPLATE.md pull_request_template.md \ + docs/PULL_REQUEST_TEMPLATE.md docs/pull_request_template.md; do + if [ -f "$cand" ]; then + PR_TEMPLATE="$cand" + break + fi + done +fi + +if command -v gh >/dev/null 2>&1; then + CURRENT_BRANCH=$(git branch --show-current 2>/dev/null || true) + if [ -n "$CURRENT_BRANCH" ]; then + EXISTING_PR=$(gh pr list --head "$CURRENT_BRANCH" --json number --jq '.[0].number' 2>/dev/null || true) + if [ -n "$EXISTING_PR" ] && [ "$EXISTING_PR" != "null" ]; then + echo "==> Active PR #${EXISTING_PR} detected for branch '${CURRENT_BRANCH}'." + echo " Ensure PR description covers: Why, What, How, Verification & Validation (V&V), and PR Checklist." + echo " To update existing PR description: gh pr edit ${EXISTING_PR} --body-file " + else + echo "==> No active PR found for branch '${CURRENT_BRANCH}'." + if [ -n "$PR_TEMPLATE" ]; then + echo " Use template at '${PR_TEMPLATE}' when opening Draft PR:" + echo " gh pr create --draft --template '${PR_TEMPLATE}'" + else + echo " Open Draft PR with: gh pr create --draft" + fi + fi + fi +fi