diff --git a/.agents/agents/adversarial_pr_reviewer/agent.md b/.agents/agents/adversarial_pr_reviewer/agent.md new file mode 100644 index 0000000..c1ef43b --- /dev/null +++ b/.agents/agents/adversarial_pr_reviewer/agent.md @@ -0,0 +1,34 @@ +# Adversarial PR Reviewer Subagent Definition + +Name: adversarial_pr_reviewer +Description: Autonomous adversarial security and domain-expert code reviewer for UltiMaker repositories. + +## System Role & Instructions + +You are an adversarial, security-focused Senior Software Architect performing autonomous code reviews across UltiMaker cloud services, web applications, core C++/WASM math libraries, and firmware ecosystems. + +### 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, GCP tokens, passwords, or API keys. + - **OWASP Compliance**: Verify against the profile-matched sections in `.agents/rules/07-owasp-security-rules.md` (IoT, API, and/or Web depending on the repo). + - **Memory & Resource Safety**: In C/C++ libraries or WASM, verify memory bounds, absence of memory leaks, and error handling that follows the convention documented in `.agents/rules/03-core-cpp-architecture-rules.md`. + +2. **Domain Architecture & Standards**: + - **Cloud Services**: Verify async handlers, query parameterization, container security, and API documentation sync. + - **Frontend & Web Apps**: Verify DESIGN.md token usage, WCAG 2.1 AA accessibility, Storybook coverage. + - **Core C++ / WASM Libraries**: Verify the repo's C++ standard, CMake/Conan presets, unit test coverage, and the documented error-handling style (std::expected vs exceptions). + - **Firmware (if applicable)**: Verify DBus proxy bindings, state machine safety, and recipe version pinning. + +3. **Work Tracking & Commit Standards**: + - **Jira Reference**: Ensure commit titles and PR title start with bracketed Jira ticket prefix `[PROJECT-KEY-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/`). + +### Output Format + +Return a structured Markdown audit report: +- 🚨 **Critical Vulnerabilities & Policy Blockers** (Must be fixed before PR approval) +- āš ļø **Warnings & Architectural Recommendations** +- āœ… **Passed Verification Checks** diff --git a/.agents/aiignore-generated.json b/.agents/aiignore-generated.json new file mode 100644 index 0000000..1472c9b --- /dev/null +++ b/.agents/aiignore-generated.json @@ -0,0 +1,105 @@ +{ + "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(./**/*.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.*", + "**/.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/bootstrap-observations.md b/.agents/bootstrap-observations.md new file mode 100644 index 0000000..75953b2 --- /dev/null +++ b/.agents/bootstrap-observations.md @@ -0,0 +1,58 @@ +# Custom Rule Proposals — ci + +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 + `20-*.md` or higher. Numbers `01`–`19` are regenerated by the bootstrap and + would overwrite your work; `20+` is never touched by `--update`. +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. + +--- + +## 1. Commits are small and tightly scoped + +**Category:** commit-style **Confidence:** medium + +**Evidence** + +- median 1 files per commit, 90th percentile 9 + +**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 9 files needs an explicit justification in the message. + +--- + +## 2. History keeps merge commits + +**Category:** git-workflow **Confidence:** medium + +**Evidence** + +- 11/40 sampled commits are merges (mostly pull request merges) +- `Merge pull request`: 11, `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..ecb3803 --- /dev/null +++ b/.agents/bootstrap-profile.json @@ -0,0 +1,129 @@ +{ + "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": 41, + "jira_keys": { + "CES": 7, + "SEM": 1 + }, + "branch_jira_key": "UC", + "history_jira_key": "CES", + "dominant_jira_key": "UC", + "semantic_prefix_ratio": 0.0 + }, + "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": [], + "commands": [] + }, + "linters": { + "ruff": false, + "black": false, + "flake8": false, + "mypy": false, + "eslint": false, + "prettier": false, + "clang_format": false, + "clang_tidy": false, + "pre_commit_config": true + }, + "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_key }}-XXXX](https://ultimaker.atlassian.net/browse/{{ jira_key }}-XXXX)\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 (`pre-commit run --all-files`)\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": "local", + "count": 9, + "extensions": ".sh" + }, + { + "name": "cfg", + "count": 7, + "extensions": ".ini, .txt" + } + ], + "artifact_dirs": [], + "has_api_surface": false, + "license": { + "declared": null, + "source": null, + "proprietary": false + }, + "has_package_json": false, + "file_count": 35, + "complexity": {}, + "skills": { + "catalog_root": "~/dev/um/UltiCortex", + "catalog_size": 25, + "matched": [ + "software-architect" + ] + }, + "observations": [ + { + "category": "commit-style", + "title": "Commits are small and tightly scoped", + "evidence": [ + "median 1 files per commit, 90th percentile 9" + ], + "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 9 files needs an explicit justification in the message.", + "confidence": "medium", + "data": {} + }, + { + "category": "git-workflow", + "title": "History keeps merge commits", + "evidence": [ + "11/40 sampled commits are merges (mostly pull request merges)", + "`Merge pull request`: 11, `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": {} + } + ] +} 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..a826c0d --- /dev/null +++ b/.agents/hooks.json @@ -0,0 +1,30 @@ +{ + "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 + } + ] + } + + ], + "PostToolUse": [ + ] + } +} diff --git a/.agents/hooks/audit_quad_agent_parity.py b/.agents/hooks/audit_quad_agent_parity.py new file mode 100755 index 0000000..50058cb --- /dev/null +++ b/.agents/hooks/audit_quad_agent_parity.py @@ -0,0 +1,203 @@ +#!/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 sys +from pathlib import Path + + +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 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 = expected - hooks + if missing: + print(f"[X] {name}: missing {sorted(missing)}") + passed = False + else: + print(f"[ok] {name}: {len(hooks)} hook(s)") + + # 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 / ".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", + "secret_scanner.py", + "path_scanner.py", + "block_name_matched_kill.py", + "compile_aiignore.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 + + print("==> Parity audit " + ("PASSED" if passed else "FAILED")) + return 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..51c95c4 --- /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 + +# 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..7be7c5c --- /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 + +# 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..d5164c6 --- /dev/null +++ b/.agents/hooks/block_name_matched_kill.py @@ -0,0 +1,78 @@ +#!/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 + ls -l /proc//cwd # confirm it is YOUR worktree + 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 + + +def main() -> int: + try: + payload = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + return 0 + + command = (payload.get("tool_input") or {}).get("command", "") + if not command: + return 0 + + 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 + + print(json.dumps({"decision": "deny", "reason": f"{reason}.\n\n{REASON}"})) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/hooks/check-relevant-scope.py b/.agents/hooks/check-relevant-scope.py new file mode 100755 index 0000000..07b640f --- /dev/null +++ b/.agents/hooks/check-relevant-scope.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +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) + +BLOCKED_VENDOR_PATHS = [ + re.compile(r"^vendor/"), + re.compile(r"^third_party/"), + re.compile(r"^node_modules/"), +] + +MAX_FILES_THRESHOLD = 50 + + +def check_scope(): + res = subprocess.run( + ["git", "diff", "--cached", "--name-only"], + capture_output=True, + text=True, + ) + staged = [f.strip() for f in res.stdout.splitlines() if f.strip()] + + blocked = [] + for f in staged: + for vp in BLOCKED_VENDOR_PATHS: + if vp.match(f): + blocked.append(f) + + if blocked: + print("SCOPE ERROR: Modifications to vendor/third-party paths blocked:") + for b in blocked: + print(f" - {b}") + sys.exit(1) + + if len(staged) > MAX_FILES_THRESHOLD: + print(f"SCOPE WARNING: Large commit staged ({len(staged)} files). Ensure commit is focused.") + + +if __name__ == "__main__": + check_scope() diff --git a/.agents/hooks/check_atomic_bisect_history.py b/.agents/hooks/check_atomic_bisect_history.py new file mode 100755 index 0000000..e4ff471 --- /dev/null +++ b/.agents/hooks/check_atomic_bisect_history.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +import os +import subprocess + +# 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) + + +def get_base_branch() -> str: + for cand in ("staging", "main", "master", "master_cheetah", "master_d-line"): + try: + subprocess.check_output( + ["git", "rev-parse", "--verify", f"origin/{cand}"], + stderr=subprocess.DEVNULL, + ) + return f"origin/{cand}" + except subprocess.CalledProcessError: + pass + return "HEAD~1" + + +def check_history(): + base_branch = get_base_branch() + 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 = [] + for c in commits: + if any(kw in c.lower() for kw in fixup_keywords): + dirty.append(c) + + if dirty: + print("HISTORY WARNING: Temporary/WIP commits detected before push:") + for d in dirty: + print(f" - {d}") + print("Consider squashing/cleaning history (`git rebase -i`) before review.") + + +if __name__ == "__main__": + check_history() diff --git a/.agents/hooks/check_file_size_budget.py b/.agents/hooks/check_file_size_budget.py new file mode 100755 index 0000000..a970d46 --- /dev/null +++ b/.agents/hooks/check_file_size_budget.py @@ -0,0 +1,426 @@ +#!/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 json +import os +import re +import subprocess +import sys +from pathlib import Path + +BUDGET = 400 +BASELINE_PATH = ".agents/file-size-baseline.json" +CHECKED_SUFFIXES: tuple[str, ...] = ( + ".py", +) +EXCLUDED_PREFIXES: tuple[str, ...] = ( + ".agents/", + "vendor/", + "third_party/", + "node_modules/", + "build/", + "software/sdk/", + "tests/", + "test/", + "ci/", +) +# Matched anywhere in the path, for vendored trees nested inside the project. +EXCLUDED_FRAGMENTS: tuple[str, ...] = () +VENDOR_COPYRIGHTS: tuple[bytes, ...] = ( + b"microsoft corporation", + b"google llc", + b"google inc", + b"apple inc", + b"arm limited", + b"stmicroelectronics", + b"nxp semiconductors", + b"texas instruments", + b"microchip", + b"silicon laboratories", + b"nordic semiconductor", + b"the qt company", + b"cmsis", +) +ESTABLISHED_PATTERNS: list[str] = [] + +_ROOT = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True +).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + + +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"externals?|deps|_deps|dist|build|proto|protos|sdk)(/|$)") + + +def looks_generated_or_vendored(path: str, staged: bool = False) -> bool: + """Machine-generated and third-party files are not ours to decompose. + + Recognised by a third-party copyright (self-evidently not ours), or by a + generator marker in a directory that is plausibly generated output. + """ + head = read_bytes(path, staged)[:4000].lower() + if not head: + return False + # A foreign copyright is sufficient on its own: we cannot refactor it. + if any(vendor in head for vendor in VENDOR_COPYRIGHTS): + return True + if any(marker in head for marker in GENERATED_MARKERS): + return bool(GENERATED_LOCATION.search(path)) + return False + + +def is_checked(path: str, extra_excludes=(), staged: bool = False) -> bool: + if not path.endswith(CHECKED_SUFFIXES): + return False + if any(path.startswith(p) for p in EXCLUDED_PREFIXES): + return False + if any(f in path for f in EXCLUDED_FRAGMENTS): + return False + if any(fragment and fragment in path for fragment in extra_excludes): + return False + return not looks_generated_or_vendored(path, staged) + + +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 read_bytes(path: str, staged: bool = False): + if staged: + result = subprocess.run(["git", "show", ":{}".format(path)], + capture_output=True) + return result.stdout if result.returncode == 0 else b"" + try: + with open(path, "rb") as handle: + return handle.read(4000) + except OSError: + return b"" + + +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 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 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 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 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 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) + baseline["files"] = grandfathered + save_baseline(baseline) + subprocess.run(["git", "add", BASELINE_PATH], check=False) + 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 + baseline["files"] = grandfathered + save_baseline(baseline) + subprocess.run(["git", "add", BASELINE_PATH], check=False) + 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, + } + save_baseline(baseline) + 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 do_report(paths): + baseline = load_baseline() + grandfathered = baseline.get("files", {}) + budget = baseline.get("budget", BUDGET) + extra = tuple(baseline.get("exclude", [])) + if not paths: + paths = [p for p in git_files("ls-files") if is_checked(p, extra)] + print("File-size headroom (budget {} lines):".format(budget)) + for path in paths: + 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 _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 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..dafede9 --- /dev/null +++ b/.agents/hooks/check_multi_intent_scope.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +""" +check_multi_intent_scope.py +Universal, Domain-Agnostic Adversarial Scope & Multi-Intent Judge. + +Works across ANY repository type (cloud frontend, embedded firmware, C++ libraries, microservices). + +Analyzes `git diff` and `git log` to evaluate whether a PR or commit serves a single cohesive +intent or bundles multiple distinct features, bug fixes, or opportunistic cleanups ("Boy Scouting"), +even within the exact same directory or module. +""" + +import os +import re +import subprocess +import sys + +_ROOT = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True +).stdout.strip() +if _ROOT: + os.chdir(_ROOT) + + +def get_git_diff_summary(): + # Diff file list + res_files = subprocess.run( + ["git", "diff", "--name-only", "HEAD"], capture_output=True, text=True + ) + files = [f.strip() for f in res_files.stdout.splitlines() if f.strip()] + if not files: + res_files = subprocess.run( + ["git", "diff", "--cached", "--name-only"], capture_output=True, text=True + ) + files = [f.strip() for f in res_files.stdout.splitlines() if f.strip()] + + # Git log summary + res_log = subprocess.run( + ["git", "log", "-n", "5", "--oneline"], capture_output=True, text=True + ) + logs = res_log.stdout.strip() + + return files, logs + + +def analyze_multi_intent(files, logs): + if not files: + return 0 + + warnings = [] + + # 1. Jira Ticket Key Multiplicity Check + jira_keys = set(re.findall(r"\b[A-Z]{2,10}-\d+\b", logs)) + if len(jira_keys) > 1: + warnings.append( + f"MULTIPLE JIRA TICKETS DETECTED in recent commits: {', '.join(sorted(jira_keys))}.\n" + " -> Each Jira ticket task SHOULD be delivered in a separate Pull Request to prevent scope coupling." + ) + + # 2. Semantic Topic Clustering Analysis (Domain-Agnostic) + components = {} + for f in files: + parts = f.split("/") + comp = "/".join(parts[:2]) if len(parts) > 1 else parts[0] + components.setdefault(comp, []).append(f) + + # 3. Diff Hunk & Intent Divergence Detection + if len(components) >= 3 and len(files) > 8: + first_line = logs.splitlines()[0] if logs else "" + warnings.append( + f"WIDE COMPONENT SPAN ({len(components)} components: {', '.join(list(components.keys())[:4])}...):\n" + f" Primary Commit: '{first_line}'\n" + " -> ADVERSARIAL JUDGMENT: Ensure all modified components directly serve this single task.\n" + " -> If any changes are opportunistic fixes or cleanups ('Boy Scouting'), extract them:\n" + " Run: bash scripts/create_boyscout_branch_and_pr.sh " + ) + + if warnings: + print("\n" + "=" * 74) + print("šŸ” ADVERSARIAL SCOPE & MULTI-INTENT JUDGE FINDINGS:") + print("=" * 74) + for w in warnings: + print(f"āš ļø {w}\n") + print("=" * 74 + "\n") + + return 0 + + +def main(): + files, logs = get_git_diff_summary() + return analyze_multi_intent(files, logs) + + +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..027992e --- /dev/null +++ b/.agents/hooks/check_security_downgrades.py @@ -0,0 +1,61 @@ +#!/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 + +SECURITY_DOWNGRADE_PATTERNS = [ + (r"signInRequired\s*[:=]\s*false", "signInRequired disabled"), + (r"needs_authentication\s*[:=]\s*false", "needs_authentication disabled"), + (r"verify\s*[:=]\s*False", "SSL/TLS verification disabled"), + (r"ssl_verify\s*[:=]\s*False", "ssl_verify disabled"), + (r"check_permissions\s*[:=]\s*false", "check_permissions disabled"), + (r"authorized\s*[:=]\s*false", "authorization disabled"), +] + + +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_lines = [line for line in diff.splitlines() if line.startswith("+") and not line.startswith("+++")] + added_content = "\n".join(added_lines) + + violations = [] + for pattern, description in SECURITY_DOWNGRADE_PATTERNS: + if re.search(pattern, added_content, re.IGNORECASE): + violations.append(f" āŒ Detected security downgrade: {description} ('{pattern}')") + + if violations: + print("\nšŸ”’ SECURITY GUARD VIOLATION DETECTED:") + for v in violations: + print(v) + print("\nAI agents are strictly forbidden from disabling security or authentication features to bypass test/verification roadblocks.") + print("Please revert the security feature downgrade and fix the underlying configuration/test issue properly.\n") + sys.exit(1) + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.agents/hooks/check_submodule_integrity.py b/.agents/hooks/check_submodule_integrity.py new file mode 100755 index 0000000..aadef73 --- /dev/null +++ b/.agents/hooks/check_submodule_integrity.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Pre-commit hook: Verify submodule tracking in git index.""" + +import configparser +import os +import subprocess +import sys + + +def get_base_branch() -> str: + for cand in ("staging", "main", "master", "master_cheetah", "master_d-line"): + try: + subprocess.check_output( + ["git", "rev-parse", "--verify", f"origin/{cand}"], + stderr=subprocess.DEVNULL, + text=True, + ) + return cand + except Exception: + pass + return "HEAD" + + +def check_submodules() -> bool: + base_branch = get_base_branch() + # 1. Check if base branch has .gitmodules when current branch lacks it + if not os.path.exists(".gitmodules"): + try: + base_modules = subprocess.check_output( + ["git", "show", f"origin/{base_branch}:.gitmodules"], + stderr=subprocess.DEVNULL, + text=True, + ) + if base_modules.strip(): + print(f"SUBMODULE INTEGRITY ERROR: .gitmodules exists on 'origin/{base_branch}' but is missing on current branch!") + print(f"Restore it with: git checkout origin/{base_branch} -- .gitmodules") + return False + except Exception: + pass + return True + + # 2. Parse .gitmodules + config = configparser.ConfigParser() + try: + config.read(".gitmodules") + except Exception as e: + print(f"SUBMODULE INTEGRITY ERROR: Failed to parse .gitmodules: {e}") + return False + + has_error = False + for section in config.sections(): + if "path" in config[section]: + submodule_path = config[section]["path"] + try: + ls_tree = subprocess.check_output( + ["git", "ls-tree", "HEAD", submodule_path], text=True + ).strip() + if not ls_tree or "160000" not in ls_tree: + print(f"SUBMODULE INTEGRITY ERROR: Submodule '{submodule_path}' is defined in .gitmodules but missing from git index!") + print(f"Restore it with: git checkout origin/{base_branch} -- {submodule_path}") + has_error = True + except Exception: + print(f"SUBMODULE INTEGRITY ERROR: Submodule '{submodule_path}' check failed in git index.") + has_error = True + + return not has_error + + +if __name__ == "__main__": + if not check_submodules(): + sys.exit(1) + sys.exit(0) diff --git a/.agents/hooks/check_upstream_alignment.py b/.agents/hooks/check_upstream_alignment.py new file mode 100755 index 0000000..a0a0dd0 --- /dev/null +++ b/.agents/hooks/check_upstream_alignment.py @@ -0,0 +1,57 @@ +#!/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 subprocess +import sys + + +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 ["main", "master", "staging"]: + sys.exit(0) + + # Fetch origin silently with short timeout + run_cmd(["git", "fetch", "origin"], timeout=8) + + # Detect base branch + base_branch = None + for cand in ["staging", "main", "master"]: + if run_cmd(["git", "rev-parse", "--verify", f"origin/{cand}"]): + base_branch = f"origin/{cand}" + break + + if not base_branch: + 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 '{current_branch}' is {behind_count} commit(s) behind '{base_branch}'.") + print(f" Please rebase or merge with '{base_branch}' before opening or updating PRs:") + print(f" git rebase {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..a3f49f1 --- /dev/null +++ b/.agents/hooks/compile_aiignore.py @@ -0,0 +1,272 @@ +#!/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/git-branch-guard.py b/.agents/hooks/git-branch-guard.py new file mode 100755 index 0000000..7376e2e --- /dev/null +++ b/.agents/hooks/git-branch-guard.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +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) + +FORBIDDEN_BRANCHES = ["main", "master", "staging"] + + +def check_branch(): + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + text=True, + ) + branch = result.stdout.strip() + if branch in FORBIDDEN_BRANCHES: + print( + f"BRANCH GUARD ERROR: Cannot commit directly to " + f"'{branch}' branch. Create a feature/bugfix branch." + ) + sys.exit(1) + + +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..57bc232 --- /dev/null +++ b/.agents/hooks/path_scanner.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +import os +import re +import subprocess +import sys +from pathlib import Path, PurePosixPath + + +class PathScanner: + PATH_CANDIDATE_REGEX = re.compile( + r"(?:/(?:[a-zA-Z0-9_.-]+/)+[a-zA-Z0-9_.-]*|/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 scan_line(cls, line: str) -> tuple[bool, list[str]]: + candidates = cls.extract_path_candidates(line) + bad_paths = [] + for candidate in candidates: + res = cls.evaluate_path_string(candidate) + if res["is_forbidden_user_path"]: + bad_paths.append(candidate) + return bool(bad_paths), bad_paths + + + @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 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 + for line in diff_cmd.stdout.splitlines(): + if line.startswith("+") and not line.startswith("+++"): + candidates = cls.extract_path_candidates(line[1:]) + for candidate in candidates: + res = cls.evaluate_path_string(candidate) + if res["is_forbidden_user_path"]: + print( + f"āŒ PathScanner blocked commit: detected hardcoded user path: {candidate}", + file=sys.stderr, + ) + found_forbidden = True + + 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..1a6d72c --- /dev/null +++ b/.agents/hooks/post-edit-linter.sh @@ -0,0 +1,31 @@ +#!/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 +CHANGED=$(git diff --name-only HEAD 2>/dev/null | head -100) +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 + python3 .agents/hooks/check_complexity.py --changed || true +fi + +if [ -f .agents/hooks/suggest-skills.py ]; then + python3 .agents/hooks/suggest-skills.py || true +fi + +exit $FAIL diff --git a/.agents/hooks/pretool_guard.py b/.agents/hooks/pretool_guard.py new file mode 100755 index 0000000..2794393 --- /dev/null +++ b/.agents/hooks/pretool_guard.py @@ -0,0 +1,210 @@ +#!/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 +from path_scanner import PathScanner + +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 "" + + +def find_violation(tool, path, blobs): + """Return a human-readable reason to block, or None to allow.""" + 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/run_adversarial_audit.py b/.agents/hooks/run_adversarial_audit.py new file mode 100755 index 0000000..6b606f3 --- /dev/null +++ b/.agents/hooks/run_adversarial_audit.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +""" +run_adversarial_audit.py +Automated Adversarial Security & Quality Gate Audit Script. +Scans current git diff for: +1. Hardcoded absolute paths (/, /) +2. Private keys, API tokens, credentials +3. Python error swallowing +4. Raw hex color strings or hardcoded pixel font declarations in QML +5. Forbidden branch commits (main, master, staging) +""" + +from pathlib import Path +import re +import subprocess +import sys + +HOME_PATH_PATTERN = re.compile(r"/home/[a-zA-Z0-9_-]+/") +USERS_PATH_PATTERN = re.compile(r"/Users/[a-zA-Z0-9_-]+/") +SECRET_PATTERNS = [ + re.compile(r"-----BEGIN (?:RSA|OPENSSH|DSA|EC|PGP) PRIVATE KEY-----"), + re.compile(r"AIzaSy[A-Za-z0-9_-]{33}"), + re.compile(r"ghp_[A-Za-z0-9]{36}"), + re.compile(r"glpat-[A-Za-z0-9_-]{20}"), +] +HEX_COLOR_PATTERN = re.compile(r"#(?:[0-9a-fA-F]{3}){1,2}\b") + + +def get_git_diff_files(): + result = subprocess.run( + ["git", "diff", "--name-only", "HEAD"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + result = subprocess.run( + ["git", "diff", "--cached", "--name-only"], + capture_output=True, + text=True, + ) + return [ + f.strip() + for f in result.stdout.splitlines() + if f.strip() and not f.strip().endswith((".pyc", ".pyo", ".png", ".jpg", ".so")) and "__pycache__" not in f + ] + + +def _check_line_patterns(filepath, idx, line, content, errors): + m_home = HOME_PATH_PATTERN.search(line) + m_user = USERS_PATH_PATTERN.search(line) + if (m_home or m_user) and "AGENTS.md" not in filepath: + if not filepath.endswith(".md"): + errors.append(f"āŒ [ABSOLUTE PATH] {filepath}:{idx}: {line.strip()}") + + for pat in SECRET_PATTERNS: + if pat.search(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 filepath.endswith(".qml") and "Theme.qml" not in filepath: + if HEX_COLOR_PATTERN.search(line): + errors.append( + f"āš ļø [QML HARDCODED HEX COLOR] {filepath}:{idx}: " + f"{line.strip()} (Use Theme.colors instead)" + ) + + +def _check_architectural_limits(files, errors): + interface_prefixes = ["griffin/interface/", "interface/http/", "endpoints/"] + interface_files = [f for f in files if any(p in f for p in interface_prefixes)] + api_doc_files = [f for f in files if "docs/api_documentation.json" in f or "openapi" in f.lower()] + if interface_files and not api_doc_files: + errors.append( + f"āŒ [API DOC DESYNC] Interface files modified " + f"({len(interface_files)} files) but docs/api_documentation.json " + "was not updated!" + ) + + vendor_prefixes = ["software/sdk/", "vendor/", "third_party/"] + vendor_files = [f for f in files if any(f.startswith(vp) for vp in vendor_prefixes)] + 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!" + ) + + if len(files) > 50: + errors.append( + f"āš ļø [EXCESSIVE DIFF] Total modified file count ({len(files)}) " + "exceeds PR scope threshold (50 files)." + ) + + +def _audit_single_file(filepath, errors): + path = Path(filepath) + if not path.exists() or path.is_dir(): + return + + skip_files = ["block-absolute-paths.py", "run_adversarial_audit.py"] + if any(sf in filepath for sf in skip_files): + 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 Exception: + pass + + +def audit_diff(): + # Execute security downgrade check hook if present + 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(f"==> Running Adversarial Security & Quality Audit on {len(files)} modified files...") + + for filepath in files: + _audit_single_file(filepath, errors) + + _check_architectural_limits(files, errors) + + if errors: + print("\n" + "=" * 74) + print("🚨 ADVERSARIAL AUDIT FINDINGS (Fix these before submitting PR):") + 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 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..f7b13f1 --- /dev/null +++ b/.agents/hooks/secret_scanner.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +import os +import re +import subprocess +import sys + +# Common API Key & Secret Token Patterns +SECRET_PATTERNS = [ + re.compile(r"(?i)aws_access_key_id\s*=\s*['\"]?(AKIA[0-9A-Z]{16})['\"]?"), + re.compile(r"(?i)aws_secret_access_key\s*=\s*['\"]?([0-9a-zA-Z/+]{40})['\"]?"), + re.compile(r"ghp_[0-9a-zA-Z]{36}"), + re.compile(r"github_pat_[0-9a-zA-Z]{22}_[0-9a-zA-Z]{59}"), + re.compile(r"AIzaSy[0-9a-zA-Z-_]{33}"), + re.compile(r"xox[baprs]-[0-9a-zA-Z]{10,48}"), + re.compile(r"sk-[0-9a-zA-Z]{32,}"), + re.compile(r"-----BEGIN (?:RSA|OPENSSH|EC|DSA) PRIVATE KEY-----"), +] + + +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 + for line in diff_cmd.stdout.splitlines(): + if line.startswith("+") and not line.startswith("+++"): + findings = cls.scan_line(line[1:]) + if findings: + print( + f"āŒ SecretScanner blocked commit: detected hardcoded 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..ccc3516 --- /dev/null +++ b/.agents/hooks/suggest-skills.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +""" +suggest-skills.py + +Advisory hook: maps the files touched in this change to the UltiCortex skills +that cover them, so the relevant expertise is loaded while the work is still +open rather than recalled during review. + +Never fails a build — steering belongs in the rules; this is the reminder. +""" + +import fnmatch +import os +import subprocess + +# 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) + + +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(): + 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 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/rules/01-jira-commit-standards.md b/.agents/rules/01-jira-commit-standards.md new file mode 100644 index 0000000..ef0c162 --- /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_description`). +2. **Commit Title Standard**: + - Every commit title MUST start with bracketed Jira ticket key: `[UC-XXXX] `. + - 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..886b692 --- /dev/null +++ b/.agents/rules/02-security-and-paths.md @@ -0,0 +1,20 @@ +--- +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 (e.g. setting `signInRequired=false`, `needs_authentication=false`, `verify=False`, or skipping scope checks) to bypass test failures or browser verification roadblocks. + - 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..e85cc20 --- /dev/null +++ b/.agents/rules/04-build-test-and-deployment-rules.md @@ -0,0 +1,20 @@ +--- +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. **Build Commands (detected)**: + - `` + - Builds must complete cleanly (no new warnings) before PR creation. + - **Container Rebuilds & Compilation Mandate**: When changing frontend or backend code in Docker or compiled environments, executing container rebuilds or asset compilation (`docker compose build ` / `npm run build`) is strictly required before visual or functional verification. + - **Package Registry Authentication**: Ensure `GITHUB_TOKEN` with `read:packages` scope is exported in your shell environment or loaded from gitignored `.env` / `.env.local` files when building services that consume private `@ultimaker` packages. Never commit plain-text credentials to git. +2. **Test Commands (detected)**: + - (no test runner detected — add one and re-run bootstrap --update) +3. **Artifact Isolation**: + - Keep generated build outputs, intermediate binaries, and logs out of git. Ensure `.env` and `.env.local` files remain strictly gitignored. + + +5. **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. 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..c812ebf --- /dev/null +++ b/.agents/rules/05-ultimaker-skill-discovery-rules.md @@ -0,0 +1,46 @@ +--- +name: 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 Architect guide for best practices (SOLID, DRY) and Design Patterns, referencing Refactoring Guru. + +**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 + +23 further skill(s) exist that no automatic trigger matched. +They are listed in `.agents/bootstrap-observations.md` 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..f0f14b4 --- /dev/null +++ b/.agents/rules/06-pull-request-lifecycle-rules.md @@ -0,0 +1,28 @@ +--- +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 (located at `.github/PULL_REQUEST_TEMPLATE.md` or `.github/workflows/PULL_REQUEST_TEMPLATE.md`) and answer the core review questions: + - **Why**: The problem, user request, Jira ticket (`[UC-XXXX]`), 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. diff --git a/.agents/rules/07-owasp-security-rules.md b/.agents/rules/07-owasp-security-rules.md new file mode 100644 index 0000000..2d003d3 --- /dev/null +++ b/.agents/rules/07-owasp-security-rules.md @@ -0,0 +1,15 @@ +--- +description: OWASP security standards selected for this repository's detected stack profile. +--- +# 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..11c57b6 --- /dev/null +++ b/.agents/rules/08-scoped-changes-and-minimal-diffs.md @@ -0,0 +1,8 @@ +--- +description: Scoped changes and minimal diff guidelines for code changes. +--- +# 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. 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..5eba579 --- /dev/null +++ b/.agents/rules/09-atomic-bisect-safe-commits.md @@ -0,0 +1,8 @@ +--- +description: Atomic, bisect-safe commit rules. +--- +# 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. 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..b11b1f3 --- /dev/null +++ b/.agents/rules/10-file-size-and-decomposition-rules.md @@ -0,0 +1,89 @@ +--- +description: File-size budget with grandfathering ratchet, and the decomposition expected to meet it. +--- +# 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..4e54980 --- /dev/null +++ b/.agents/rules/13-dependency-management-rules.md @@ -0,0 +1,80 @@ +--- +description: Reuse before rebuild — search existing and published dependencies, and check licence compatibility, before writing new code. +--- +# 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. diff --git a/.agents/rules/15-git-merge-topology-rules.md b/.agents/rules/15-git-merge-topology-rules.md new file mode 100644 index 0000000..f8958e8 --- /dev/null +++ b/.agents/rules/15-git-merge-topology-rules.md @@ -0,0 +1,12 @@ +--- +name: git-merge-topology +description: Git merge topology preservation and branch integration standards. +trigger: always_on +--- + +# 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/16-sibling-ecosystem-dependencies-rules.md b/.agents/rules/16-sibling-ecosystem-dependencies-rules.md new file mode 100644 index 0000000..ccbd269 --- /dev/null +++ b/.agents/rules/16-sibling-ecosystem-dependencies-rules.md @@ -0,0 +1,17 @@ +--- +name: sibling-ecosystem-dependencies +description: Cross-repository API contracts, G-Code synchronization, and sibling ecosystem dependency alignment. +trigger: always_on +--- + +# Sibling Ecosystem Repositories & Cross-Dependencies + +Respect API contracts across sibling UltiMaker ecosystem repositories (`opinicus`, `ultimoco`, `okuda`, `stardust-embedded`, `marvin-service`, `jedi-build`, `jedi-cookbook`, `dbus-interface-lib`): +1. **No Unilateral Interface Changes**: Never modify DBus signals, REST payloads, G-Code commands, or C structs without coordinating across sibling repositories. +2. **Synchronized DBus Proxies**: Do not edit generated DBus proxies locally; update the canonical source (`dbus-interface-lib` or interface definition files) and regenerate proxies. +3. **Build Recipe Alignment**: Ensure CMake packaging and Debian control dependencies align with build recipes in `jedi-cookbook`. +4. **Mandatory Ecosystem G-Code Synchronization**: + - Whenever adding or modifying a standard G-Code or custom M-Code (`M120xx`, `G150`, `G280`, `M2000`+) in any repository (`opinicus`, `ultimoco`, `okuda`, `stardust-embedded`, `marvin-service`), you MUST: + a) Register the new code and parameter definitions in `docs/api_documentation.json`. + b) Synchronize parser, dispatch, and handler implementations in sibling repositories that send, forward, or receive G-Code over DBus, serial, or WebSocket channels. + c) Update `03-marlin-gcode-architecture-rules.md` in `UltiCortex` so all AI agents across the organization remain aware of the updated G-Code ecosystem contract. diff --git a/.agents/rules/17-industry-best-practices-rules.md b/.agents/rules/17-industry-best-practices-rules.md new file mode 100644 index 0000000..d9e7516 --- /dev/null +++ b/.agents/rules/17-industry-best-practices-rules.md @@ -0,0 +1,39 @@ +--- +description: Industry best practices, multi-dimensional code search evaluation (Security, Reliability, Agentic Readiness, Efficiency, SOLID), and code-best-practices-search skill integration before bespoke implementations. +--- +# 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/19-semantic-release-versioning-rules.md b/.agents/rules/19-semantic-release-versioning-rules.md new file mode 100644 index 0000000..e8d5286 --- /dev/null +++ b/.agents/rules/19-semantic-release-versioning-rules.md @@ -0,0 +1,15 @@ +--- +name: semantic-release-versioning +description: Semantic release versioning and release tagging standards. +trigger: always_on +--- + +# 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-XXXX] ` standard. +3. **Version Synchronization**: + - Keep version numbers synchronized across DEBIAN `changelog` / `control`, `CMakeLists.txt`, and package manifests (`package.json`, `setup.py`). diff --git a/.agents/rules/31-multi-intent-and-boyscout-isolation-rules.md b/.agents/rules/31-multi-intent-and-boyscout-isolation-rules.md new file mode 100644 index 0000000..e95c5db --- /dev/null +++ b/.agents/rules/31-multi-intent-and-boyscout-isolation-rules.md @@ -0,0 +1,35 @@ +--- +name: multi-intent-and-boyscout-isolation +description: Enforce Single Responsibility Principle for Pull Requests (SRP-PR) and mandate Boy Scouting branch isolation for multi-intent changes. +trigger: always_on +--- + +# 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 +``` +If the judge flags multi-intent scope divergence, extract the secondary changes before requesting human review. diff --git a/.aiignore b/.aiignore new file mode 100644 index 0000000..80bb901 --- /dev/null +++ b/.aiignore @@ -0,0 +1,78 @@ +# .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.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/.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/hooks.json b/.claude/hooks.json new file mode 100644 index 0000000..a826c0d --- /dev/null +++ b/.claude/hooks.json @@ -0,0 +1,30 @@ +{ + "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 + } + ] + } + + ], + "PostToolUse": [ + ] + } +} 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-git-merge-topology-rules.md b/.claude/rules/15-git-merge-topology-rules.md new file mode 120000 index 0000000..1c6b073 --- /dev/null +++ b/.claude/rules/15-git-merge-topology-rules.md @@ -0,0 +1 @@ +../../.agents/rules/15-git-merge-topology-rules.md \ No newline at end of file diff --git a/.claude/rules/16-sibling-ecosystem-dependencies-rules.md b/.claude/rules/16-sibling-ecosystem-dependencies-rules.md new file mode 120000 index 0000000..74cc64f --- /dev/null +++ b/.claude/rules/16-sibling-ecosystem-dependencies-rules.md @@ -0,0 +1 @@ +../../.agents/rules/16-sibling-ecosystem-dependencies-rules.md \ No newline at end of file diff --git a/.claude/rules/17-industry-best-practices-rules.md b/.claude/rules/17-industry-best-practices-rules.md new file mode 120000 index 0000000..5f98681 --- /dev/null +++ b/.claude/rules/17-industry-best-practices-rules.md @@ -0,0 +1 @@ +../../.agents/rules/17-industry-best-practices-rules.md \ No newline at end of file diff --git a/.claude/rules/19-semantic-release-versioning-rules.md b/.claude/rules/19-semantic-release-versioning-rules.md new file mode 120000 index 0000000..dd59d85 --- /dev/null +++ b/.claude/rules/19-semantic-release-versioning-rules.md @@ -0,0 +1 @@ +../../.agents/rules/19-semantic-release-versioning-rules.md \ No newline at end of file diff --git a/.claude/rules/31-multi-intent-and-boyscout-isolation-rules.md b/.claude/rules/31-multi-intent-and-boyscout-isolation-rules.md new file mode 120000 index 0000000..96d7b62 --- /dev/null +++ b/.claude/rules/31-multi-intent-and-boyscout-isolation-rules.md @@ -0,0 +1 @@ +../../.agents/rules/31-multi-intent-and-boyscout-isolation-rules.md \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..f07d555 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,73 @@ +{ + "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" + } + ] + } + ] + }, + "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(./**/*.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/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..92f86b3 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,21 @@ +Jira ticket: [UC-XXXX](https://ultimaker.atlassian.net/browse/UC-XXXX) + +## Why + + +## What + + +## How + + +## Verification & Validation (V&V) + +- [ ] Automated unit/integration tests pass +- [ ] Pre-commit static checks pass cleanly (`pre-commit run --all-files`) +- [ ] 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/.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/copilot-content-exclusion.yml b/.github/copilot-content-exclusion.yml new file mode 100644 index 0000000..3c6b3d3 --- /dev/null +++ b/.github/copilot-content-exclusion.yml @@ -0,0 +1,59 @@ +# 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.*" + - "/**/*.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/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..dfb6843 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,18 @@ +# 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 rule +files in [.agents/rules/](../.agents/rules/). + +Apply, in order: +1. `AGENTS.md` — operational guide, tech stack, directory layout, PR flow. +2. `.agents/rules/*.md` — numbered rules (Jira/commit standards, security, + domain architecture, skill discovery, PR lifecycle, OWASP, scope, atomic + commits). +3. `DESIGN.md` (if present) — design tokens for any UI work. + +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..f75fcb1 --- /dev/null +++ b/.github/hooks/copilot-hooks.json @@ -0,0 +1,21 @@ +{ + "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 + } + + ], + "postToolUse": [ + ] + } +} diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..941f10c --- /dev/null +++ b/.ignore @@ -0,0 +1,80 @@ +# 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.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/.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/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-git-merge-topology-rules.md b/.opencode/rules/15-git-merge-topology-rules.md new file mode 120000 index 0000000..1c6b073 --- /dev/null +++ b/.opencode/rules/15-git-merge-topology-rules.md @@ -0,0 +1 @@ +../../.agents/rules/15-git-merge-topology-rules.md \ No newline at end of file diff --git a/.opencode/rules/16-sibling-ecosystem-dependencies-rules.md b/.opencode/rules/16-sibling-ecosystem-dependencies-rules.md new file mode 120000 index 0000000..74cc64f --- /dev/null +++ b/.opencode/rules/16-sibling-ecosystem-dependencies-rules.md @@ -0,0 +1 @@ +../../.agents/rules/16-sibling-ecosystem-dependencies-rules.md \ No newline at end of file diff --git a/.opencode/rules/17-industry-best-practices-rules.md b/.opencode/rules/17-industry-best-practices-rules.md new file mode 120000 index 0000000..5f98681 --- /dev/null +++ b/.opencode/rules/17-industry-best-practices-rules.md @@ -0,0 +1 @@ +../../.agents/rules/17-industry-best-practices-rules.md \ No newline at end of file diff --git a/.opencode/rules/19-semantic-release-versioning-rules.md b/.opencode/rules/19-semantic-release-versioning-rules.md new file mode 120000 index 0000000..dd59d85 --- /dev/null +++ b/.opencode/rules/19-semantic-release-versioning-rules.md @@ -0,0 +1 @@ +../../.agents/rules/19-semantic-release-versioning-rules.md \ No newline at end of file diff --git a/.opencode/rules/31-multi-intent-and-boyscout-isolation-rules.md b/.opencode/rules/31-multi-intent-and-boyscout-isolation-rules.md new file mode 120000 index 0000000..96d7b62 --- /dev/null +++ b/.opencode/rules/31-multi-intent-and-boyscout-isolation-rules.md @@ -0,0 +1 @@ +../../.agents/rules/31-multi-intent-and-boyscout-isolation-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/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3a17f28 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,119 @@ +default_stages: [pre-commit] +exclude: "^(software/.*|vendor/.*|third_party/.*|build/.*|node_modules/)" +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-yaml + args: [--unsafe] + - id: check-json + exclude: '^(griffin/machines/.*\.json)' + - id: check-added-large-files + args: ['--maxkb=2000'] + - id: end-of-file-fixer + exclude: '^(software/|vendor/|third_party/|node_modules/|docs/|hardware/)' + - id: trailing-whitespace + exclude: '^(software/|vendor/|third_party/|node_modules/|docs/|hardware/)' + + - 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-relevant-scope + name: Check relevant scope and block scope creep + entry: python3 .agents/hooks/check-relevant-scope.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-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: check-submodule-integrity + name: Verify submodule tracking in git index + entry: python3 .agents/hooks/check_submodule_integrity.py + 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: suggest-skills + name: Suggest relevant UltiCortex domain skills + entry: python3 .agents/hooks/suggest-skills.py + language: system + 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..3484ff5 --- /dev/null +++ b/.talismanrc @@ -0,0 +1,74 @@ +fileignoreconfig: +- filename: .agents/rules/01-jira-commit-standards.md + checksum: 00eabbabe32a14c6e3f24ad1d01d95586e02954b8395951ecba78fe4ef98c98d +- filename: .agents/rules/02-security-and-paths.md + checksum: 8cd08787d85a73c4586a9aef4ade8f12928cdd225f6a35757177b29d873b390a +- filename: .agents/rules/04-build-test-and-deployment-rules.md + checksum: 262d57b34f7a1e28b2827c47c39993cef3b7bce6e6ccde49b283ff170b77e10e +- filename: .agents/rules/05-ultimaker-skill-discovery-rules.md + checksum: 40d41bd82edcfa3d86971680a307df168d5d1d37bdf7898b3edca78e9074d68a +- filename: .agents/rules/06-pull-request-lifecycle-rules.md + checksum: d38e3145e60b5b21e1336e7688c0b26276f25470e9fddb0ba5f9eb539a7fb9ea +- filename: .agents/rules/07-owasp-security-rules.md + checksum: 75a0615a4fe300c0fb0dee78fbcdb4a0715c02becc1893bf315fa04ef8f437a3 +- filename: .agents/rules/08-scoped-changes-and-minimal-diffs.md + checksum: 19a967193f698b418b0a74c8abab6746ecb49f4b2acebdbf8834a4a6fd0ac481 +- filename: .agents/rules/09-atomic-bisect-safe-commits.md + checksum: 6b2ef6dd4b05b7ccd1f53b1543c005912d12ca7a7191eb0aa1d998e1e0412997 +- filename: .agents/rules/10-file-size-and-decomposition-rules.md + checksum: 06d32cb3b73336d6c4d7b95c85da257afd3b202edad7f226c2c00f8162105de4 +- filename: .agents/rules/12-ai-context-exclusion-rules.md + checksum: dc3997cdaf749718dc3986453d72b889655f55b15f781f4042fc1b551269d946 +- filename: .agents/rules/13-dependency-management-rules.md + checksum: dadcfa53f7efc1c44e762936fed332c78b74daa917706703d29c0d634e2d9c3c +- filename: .agents/rules/15-git-merge-topology-rules.md + checksum: 1b2b15116c5f25e866efbef5c1d15f3ecf08db3df5a32609e30236f5c4955753 +- filename: .agents/rules/16-sibling-ecosystem-dependencies-rules.md + checksum: ce631bff0bbbde0f5d69a2a4a4db81ec2cf9e40ba76c7262515b6887d99b1b46 +- filename: .agents/rules/17-industry-best-practices-rules.md + checksum: f308d76ae371646b088cedacae2f1eee7ba796a2578105dee6d362932cea8d5e +- filename: .agents/rules/19-semantic-release-versioning-rules.md + checksum: e77c632d562954d437cdeb0d7e5cfaade1ccfd2224294c2173de233647c66107 +- filename: .agents/rules/31-multi-intent-and-boyscout-isolation-rules.md + checksum: 829a1e904dcc222a38d3cd5d0529c5dda70306015bfaf587950a4d83ab05e88b +- filename: .agents/hooks/audit_quad_agent_parity.py + checksum: 55395b3e5463b00fc1e9c8ca976eb203ed7feb49904f2d29716c3ed76b034c59 +- filename: .agents/hooks/block-absolute-paths.py + checksum: 1bf8bbd145d181069876d9448d11f15719ae899809e51ad8d97a73725d133633 +- filename: .agents/hooks/block-secrets.py + checksum: df7ae2ab1c05b272fdf68e5cf06179e2ffa4cfcd5b6e3de24819f652ce98cb1d +- filename: .agents/hooks/block_name_matched_kill.py + checksum: 30be4165ff0d2fb050a2b2aabdc90fe0d5862359f246c244cfda127bb25de9c0 +- filename: .agents/hooks/check-relevant-scope.py + checksum: 55fb6457511774f9d6ffeadbf1a2ab94debe0be3e56680ff9ba1ff78a9d23178 +- filename: .agents/hooks/check_atomic_bisect_history.py + checksum: feef34090be766040ceae49797a0b04761789973d720dc745486d504fc597c2b +- filename: .agents/hooks/check_file_size_budget.py + checksum: bbc6d8179174953888dbc2fd411d4d99d0e672fa23406c3d2cb321a781c444dc +- filename: .agents/hooks/check_multi_intent_scope.py + checksum: a7691b75b0dd02695b3d2f318aeecdea2e984fc307a60128c64f62eef2124280 +- filename: .agents/hooks/check_security_downgrades.py + checksum: 645774dd6ae4702d22f21721defcd0403ad16f4df5f4cd8d217c5edb6798c869 +- filename: .agents/hooks/check_submodule_integrity.py + checksum: dfc5d774ad74797591c6f1ffbf85387e3f9380e4d9c11b6b866398773f7feabf +- filename: .agents/hooks/check_upstream_alignment.py + checksum: ee7f9dc8742861b7a93cda680ac2e33367e197a2aebcf75b7e58c07048f9e845 +- filename: .agents/hooks/compile_aiignore.py + checksum: 8700a459871e11d72db58ff9105127c41894b1391db7622d087077535fc25e42 +- filename: .agents/hooks/git-branch-guard.py + checksum: 781e72d414f2abe2724c111b8e3757712ff359ec7769470392d4364fc6233ca1 +- filename: .agents/hooks/path_scanner.py + checksum: c4df44c938625de4ac6786bd944aefb8a5df2463c4c0a80888d3eec9c5c8aa35 +- filename: .agents/hooks/pretool_guard.py + checksum: 8763910ca50dfb887634d6c0010e5d7e0bdb6835d438f5972b6bb0309ab911e8 +- filename: .agents/hooks/run_adversarial_audit.py + checksum: 0de01c9b00aeeb584b97ddc60080714458cbceea3f7e0ca030bd932397cda42f +- filename: .agents/hooks/secret_scanner.py + checksum: 206e40ade9fe76e3fec0bd692e9550f1118df2f90511dd4240866b701a40965d +- filename: .agents/hooks/suggest-skills.py + checksum: bc553618505dcf573e960d1a083aade4e70ca80056f8a243fe7d342d49090787 +- filename: .agents/agents/adversarial_pr_reviewer/agent.md + checksum: 3c4e1faadc8cd9778eaf3f73b16d9bbc8d0f04fb4d6fec6da631c31d92fafe28 +- filename: .github/copilot-instructions.md + checksum: fda84d43ea0dcdd05fea0d09565b735a5272ddbc0d72aa48d0a7d38d2e196c13 +version: "" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..45cf9d4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,95 @@ +# 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 + +This repository provides the shared static code analysis and CI/CD tools for the libCharon project and its companion repositories (such as dbus-interface-lib, marvin-service, libPalantir, libSmeagol, libLogger, ultiLib). It standardizes execution of Python linters (flake8, mypy, pycodestyle, pylint, vulture), shell checks, and unit tests (pytest). If broken, it can disrupt CI pipelines across multiple backend/system libraries in the ecosystem. + +**Repository:** `ci` +**Ecosystem context:** UltiMaker Ecosystem + +## 2. Position in the Wider System + +This CI repository is consumed by GitHub Actions (as Reusable Workflows and Actions) and locally by developers via Docker. It operates on companion Python repositories (e.g. `libCharon`, `libSmeagol`, etc.). It does not expose a programmatic API but rather bash scripts and configuration files used by other repos' `.github/workflows`. + +## 3. Tech Stack + +The stack consists of Bash shell scripts for execution orchestration and INI/text configuration files for Python tooling (`flake8`, `mypy`, `pycodestyle`, `pylint`, `pytest`). All scripts are designed to run within a Docker container or GitHub Actions runner. + +## 4. Directory Map + +| Directory | Files | Predominant types | Purpose | +|---|---|---|---| +| `local/` | 9 | .sh | Contains shell scripts that enable developers to execute the CI steps locally inside a Docker container. | +| `cfg/` | 7 | .ini, .txt | Configuration files for the Python code analysis tools (e.g., `flake8.ini`, `mypy.ini`, `.pylintrc`, etc.). | + +Fill the Purpose column from what the code in each directory actually does. Delete rows for directories an agent never needs to touch. + +## 5. Local Development + +### Prerequisites + +To run locally, you need Docker installed. The scripts in `local/` orchestrate running these linters inside a container context. + +### Build + +There is no build process for this repository as it consists solely of scripts and configuration files. + +### Test + +The repository itself does not have automated tests; its scripts are used to test *other* repositories. Run the scripts in `local/` on a target Python codebase to verify their behavior. + +### Run / Deploy Locally + +Changes here are typically tested by executing them locally via the scripts in `local/` against a companion repo (e.g., `libCharon`), or by opening a PR and verifying that the GitHub Actions Reusable Workflows execute correctly on the dependent repository. + +### Continuous Integration + +CI runs on github-actions and covers tests, lint. Reproduce a CI failure locally before pushing a fix. + +## 6. Architecture & Domain Concepts + +- **Exclusion Files:** Individual repositories can exclude files from linting by creating a `linting_excluded_files.txt` file in their root. This file is parsed by `references.sh`. +- **Reusable Workflows:** Provide standardized execution of checks (`Flake8`, `MyPy`, `PyCodeStyle`, `PyLint`, `PyTest`, `Vulture`) comparing against a `Parent_Branch`. +- **Companion Repositories Action:** Automates cloning of internal peer repos (`dbus-interface-lib`, `marvin-service`, `libPalantir`, `libSmeagol`, `libCharon`, `libLogger`, `ultiLib`) into the runner environment. + +## 7. Verification & Validation + +A change works if the linting scripts correctly run on a target repository and produce the expected pass/fail status without syntax errors or runtime issues in the shell script. Verify by executing one of the `local/run_*.sh` scripts. + +## 8. 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 is blocked regardless of intent? | `.agents/hooks/*` and `.pre-commit-config.yaml` | +| **Design tokens** | Which colour, spacing, type do I use? | `DESIGN.md` | +| **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` | + +Rules are numbered: `01`–`19` are generated by the agentic bootstrap and are +overwritten on update; `20` and above are hand-written for this repository and +are never touched. + + +### 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` 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 120000 index 0000000..47dc3e3 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/cfg/pytest.ini b/cfg/pytest.ini index 4b0e53f..24854dd 100644 --- a/cfg/pytest.ini +++ b/cfg/pytest.ini @@ -1,6 +1,6 @@ [pytest] testpaths = ./tests -python_files = test*.py +python_files = [Tt]est*.py python_classes = *Test Test* log_cli = False log_cli_level = WARNING diff --git a/linting_excluded_files.txt b/linting_excluded_files.txt new file mode 100644 index 0000000..af0c92a --- /dev/null +++ b/linting_excluded_files.txt @@ -0,0 +1 @@ +.agents/ diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..bd4637a --- /dev/null +++ b/opencode.json @@ -0,0 +1,163 @@ +{ + "$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", + "**/*.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", + "**/*.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", + "**/*.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" + } + } +} diff --git a/scripts/sync_agentic_configs.sh b/scripts/sync_agentic_configs.sh new file mode 100755 index 0000000..6a7a967 --- /dev/null +++ b/scripts/sync_agentic_configs.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +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 +find .claude/rules/ .opencode/rules/ -xtype l -delete 2>/dev/null || true + +# Copy hook configs across platforms +cp -f .agents/hooks.json .claude/hooks.json 2>/dev/null || true + +# Symlink AGENTS.md for platforms expecting CLAUDE.md or GEMINI.md. +if [ -f AGENTS.md ] && [ ! -L AGENTS.md ]; then + if [ ! -e CLAUDE.md ]; then + ln -s AGENTS.md CLAUDE.md + elif [ -L CLAUDE.md ] && [ "$(readlink CLAUDE.md)" = "AGENTS.md" ]; then + : # already correct + fi + if [ ! -e GEMINI.md ]; then + ln -s AGENTS.md GEMINI.md + elif [ -L GEMINI.md ] && [ "$(readlink GEMINI.md)" = "AGENTS.md" ]; then + : # already correct + fi +fi + +# Symlink rules from .agents/rules to .claude/rules and .opencode/rules +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 +fi + +# Symlink subagent definitions across platforms +if [ -d .agents/agents/adversarial_pr_reviewer ]; then + src="../../.agents/agents/adversarial_pr_reviewer/agent.md" + ln -sf "$src" ".claude/agents/adversarial_pr_reviewer.md" 2>/dev/null || true + ln -sf "$src" ".opencode/agents/adversarial_pr_reviewer.md" 2>/dev/null || true + ln -sf "$src" ".github/agents/adversarial_pr_reviewer.md" 2>/dev/null || true +fi + +# Symlink AGENTS.md for opencode rules +if [ -f AGENTS.md ]; then + ln -sf "../../AGENTS.md" ".opencode/rules/agents.md" 2>/dev/null || 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 + +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..b892812 --- /dev/null +++ b/scripts/verify_and_create_pr.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "==> Running Pre-PR Verification & Quality Gate Audit..." +if command -v pre-commit >/dev/null 2>&1; then + pre-commit run --all-files || { 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..." +for doc in AGENTS.md DESIGN.md; do + [ -f "$doc" ] || continue + n=$(grep -c "TODO(agent)" "$doc" || true) + if [ "$n" -gt 0 ]; then + echo "[X] $doc still has $n unfilled TODO(agent) marker(s)." + echo " An orientation document full of placeholders is worse than none:" + echo " agents read it, learn nothing, and trust it anyway." + echo " Fill the sections from the repository before opening a PR:" + grep -n "TODO(agent)" "$doc" | head -10 + exit 1 + fi +done + +if [ -f .agents/hooks/run_adversarial_audit.py ]; then + python3 .agents/hooks/run_adversarial_audit.py || { echo "āŒ Adversarial audit failed!"; exit 1; } +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 .env .env.local 2>/dev/null | 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 PR template +PR_TEMPLATE="" +for cand in .github/PULL_REQUEST_TEMPLATE.md .github/pull_request_template.md .github/workflows/PULL_REQUEST_TEMPLATE.md; do + if [ -f "$cand" ]; then + PR_TEMPLATE="$cand" + break + fi +done + +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