From 84aac0547248deeea39e55f389f385c58ab1f052 Mon Sep 17 00:00:00 2001 From: lusoris Date: Sun, 8 Nov 2026 13:57:34 +0100 Subject: [PATCH 1/4] chore(governance): re-adopt Praetor at engine 5dfdd28b34d4 The installed engine (5dfdd28b34d4) renders the branch ruleset with a copilot_code_review rule, so `praetorctl audit` reported ruleset drift on main and the pre-commit audit refused every commit. `praetorctl compile-context --verify` also failed on main: the four exceptions lacked the category the engine now requires. - .github/rulesets/main.json: rendered by `praetorctl sync` from the declared policy; adds the copilot_code_review rule. - .standards.yaml: each exception gains `entered: "2026-10-22"` and `category: A` (the same hunk as #10). - `praetorctl adopt` refreshed lefthook.yml (pre-push secrets, docstrings and merge-check jobs; agent checkpoint hooks removed), renovate.json (engine pin custom manager), .gitignore (Codacy artefacts), .config/agent/hooks/block_evasion.py (string wrapping only) and the README governance badge link, and created .coderabbit.yaml (review drafts on every base branch). The live ruleset on GitHub is unchanged; applying the file there is a separate step. Signed-off-by: lusoris --- .coderabbit.yaml | 7 + .config/agent/hooks/block_evasion.py | 188 +++++++++++++-------------- .github/rulesets/main.json | 9 +- .gitignore | 3 + .standards.yaml | 16 ++- README.md | 3 +- lefthook.yml | 30 +++-- renovate.json | 17 +++ 8 files changed, 161 insertions(+), 112 deletions(-) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..a51cc8e --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +--- +reviews: + auto_review: + drafts: true + base_branches: + - .* diff --git a/.config/agent/hooks/block_evasion.py b/.config/agent/hooks/block_evasion.py index e22c3a3..321b6a1 100755 --- a/.config/agent/hooks/block_evasion.py +++ b/.config/agent/hooks/block_evasion.py @@ -55,9 +55,9 @@ (r"SKIP=.*git", HISS_REFUSAL), (r"(?i:core\.hookspath)(\s*=|\s+[\x22\x27]?[/~.$A-Za-z_\\])", HISS_REFUSAL), ( - r"\b(?i:rm|rmdir|unlink|mv|cp|ln|chmod|chown|chattr|truncate|shred|tee|del|era" - r"se|rd|ri|remove-item|move|move-item|ren|rename|rename-item|copy|copy-item|se" - r"t-content|add-content|out-file|icacls|attrib)\b[^\n]*(?i:\.git[/\\]hooks)", + r"\b(?i:rm|rmdir|unlink|mv|cp|ln|chmod|chown|chattr|truncate|shred|tee|del|" + r"erase|rd|ri|remove-item|move|move-item|ren|rename|rename-item|copy|copy-item" + r"|set-content|add-content|out-file|icacls|attrib)\b[^\n]*(?i:\.git[/\\]hooks)", HISS_REFUSAL, ), ( @@ -75,10 +75,10 @@ (r">\s*[\x22\x27]?[^ \t\n\x22\x27]*(?i:\.git[/\\]hooks)", HISS_REFUSAL), (r"\blefthook\s+uninstall\b", HISS_REFUSAL), ( - r"(?m)^(?:(?:[^\Wg]\w*|g(?:[^\Wh]\w*|h\w+)?)?[^\w\n])*(gh)\b[^\n]*(?:\bpr\s+me" - r"rge\b[^\n]*(?:\s|[\x22\x27])--admin(?:\s|$|[;&|\x22\x27]|=true\b)|(?:\s|[\x2" - r"2\x27])--admin(?:\s|$|[;&|\x22\x27]|=true\b)[^\n]*\bpr\s+merge\b|\bpr\s+--ad" - r"min(?:\s|$|[;&|\x22\x27]|=true\b)[^\n]*\bmerge\b)", + r"(?m)^(?:(?:[^\Wg]\w*|g(?:[^\Wh]\w*|h\w+)?)?[^\w\n])*(gh)\b[^\n]*(?:\bpr\s+" + r"merge\b[^\n]*(?:\s|[\x22\x27])--admin(?:\s|$|[;&|\x22\x27]|=true\b)|(?:\s|[" + r"\x22\x27])--admin(?:\s|$|[;&|\x22\x27]|=true\b)[^\n]*\bpr\s+merge\b|\bpr\s+-" + r"-admin(?:\s|$|[;&|\x22\x27]|=true\b)[^\n]*\bmerge\b)", HISS_REFUSAL, ), ( @@ -87,8 +87,8 @@ HISS_REFUSAL, ), ( - r"(?m)^(?:(?:[^\Wg]\w*|g(?:[^\Wh]\w*|h\w+)?)?[^\w\n])*(gh)\b[^\n]*\bapi\s+grap" - r"hql\b[^\n]*\bmergePullRequest\b", + r"(?m)^(?:(?:[^\Wg]\w*|g(?:[^\Wh]\w*|h\w+)?)?[^\w\n])*(gh)\b[^\n]*\bapi\s+" + r"graphql\b[^\n]*\bmergePullRequest\b", HISS_REFUSAL, ), ( @@ -192,37 +192,37 @@ r"&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x64][^ \t\n;&|()\x60\x22\x27]*" r")?|[^ \t\n;&|()\x60\x22\x27\x61][^ \t\n;&|()\x60\x22\x27]*)?|\x6e(?:[^ \t\n;" r"&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|[^ \t\n;&|()\x60\x22\x27\x2d\x6" - r"e][^ \t\n;&|()\x60\x22\x27]*)))*[ \t]+|(?:[^ \t\n;&|()\x60\x22\x27]*/)?(?:no" - r"hup)(?:[ \t]+(?:-(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)))*[" - r" \t]+|(?:[^ \t\n;&|()\x60\x22\x27]*/)?(?:setsid)(?:[ \t]+(?:-(?:[^ \t\n;&|()" - r"\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)))*[ \t]+|(?:[^ \t\n;&|()\x60\x22\x2" - r"7]*/)?(?:stdbuf)(?:[ \t]+(?:(?:-i|-o|-e)[ \t]+(?:\x22[^\x22\n]*\x22|\x27[^\x" - r"27\n]*\x27|[^- \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*|-[0-9]+)|-(?" - r":\x65(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|\x69(?:[^ \t\n;" - r"&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|\x6f(?:[^ \t\n;&|()\x60\x22\x27" - r"][^ \t\n;&|()\x60\x22\x27]*)|[^ \t\n;&|()\x60\x22\x27\x65\x69\x6f][^ \t\n;&|" - r"()\x60\x22\x27]*)))*[ \t]+|(?:[^ \t\n;&|()\x60\x22\x27]*/)?(?:systemd-run)(?" - r":[ \t]+(?:(?:-p|--property|-u|--unit|-E|--setenv|--slice|-M|--machine|-H|--h" - r"ost|--uid|--gid|--nice|--working-directory|--description)[ \t]+(?:\x22[^\x22" - r"\n]*\x22|\x27[^\x27\n]*\x27|[^- \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x" - r"27]*|-[0-9]+)|-(?:\x2d(?:\x64(?:\x65(?:\x73(?:\x63(?:\x72(?:\x69(?:\x70(?:\x" - r"74(?:\x69(?:\x6f(?:\x6e(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]" - r"*)|[^ \t\n;&|()\x60\x22\x27\x6e][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x6" - r"0\x22\x27\x6f][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x69][^ " - r"\t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x74][^ \t\n;&|()\x60\x22" - r"\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x70][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&" - r"|()\x60\x22\x27\x69][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x7" - r"2][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x63][^ \t\n;&|()\x60" - r"\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x73][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t" - r"\n;&|()\x60\x22\x27\x65][^ \t\n;&|()\x60\x22\x27]*)?|\x67(?:\x69(?:\x64(?:[^" - r" \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|[^ \t\n;&|()\x60\x22\x27" - r"\x64][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x69][^ \t\n;&|()" - r"\x60\x22\x27]*)?|\x68(?:\x6f(?:\x73(?:\x74(?:[^ \t\n;&|()\x60\x22\x27][^ \t" - r"\n;&|()\x60\x22\x27]*)|[^ \t\n;&|()\x60\x22\x27\x74][^ \t\n;&|()\x60\x22\x27" - r"]*)?|[^ \t\n;&|()\x60\x22\x27\x73][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()" - r"\x60\x22\x27\x6f][^ \t\n;&|()\x60\x22\x27]*)?|\x6d(?:\x61(?:\x63(?:\x68(?:\x" - r"69(?:\x6e(?:\x65(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|[^ " - r"\t\n;&|()\x60\x22\x27\x65][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22" + r"e][^ \t\n;&|()\x60\x22\x27]*)))*[ \t]+|(?:[^ \t\n;&|()\x60\x22\x27]*/)?(?:" + r"nohup)(?:[ \t]+(?:-(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)))" + r"*[ \t]+|(?:[^ \t\n;&|()\x60\x22\x27]*/)?(?:setsid)(?:[ \t]+(?:-(?:[^ \t\n;&|" + r"()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)))*[ \t]+|(?:[^ \t\n;&|()\x60\x22" + r"\x27]*/)?(?:stdbuf)(?:[ \t]+(?:(?:-i|-o|-e)[ \t]+(?:\x22[^\x22\n]*\x22|\x27[" + r"^\x27\n]*\x27|[^- \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*|-[0-9]+)|" + r"-(?:\x65(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|\x69(?:[^ \t" + r"\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|\x6f(?:[^ \t\n;&|()\x60\x22" + r"\x27][^ \t\n;&|()\x60\x22\x27]*)|[^ \t\n;&|()\x60\x22\x27\x65\x69\x6f][^ \t" + r"\n;&|()\x60\x22\x27]*)))*[ \t]+|(?:[^ \t\n;&|()\x60\x22\x27]*/)?(?:systemd-" + r"run)(?:[ \t]+(?:(?:-p|--property|-u|--unit|-E|--setenv|--slice|-M|--machine|" + r"-H|--host|--uid|--gid|--nice|--working-directory|--description)[ \t]+(?:\x22" + r"[^\x22\n]*\x22|\x27[^\x27\n]*\x27|[^- \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60" + r"\x22\x27]*|-[0-9]+)|-(?:\x2d(?:\x64(?:\x65(?:\x73(?:\x63(?:\x72(?:\x69(?:\x7" + r"0(?:\x74(?:\x69(?:\x6f(?:\x6e(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x2" + r"2\x27]*)|[^ \t\n;&|()\x60\x22\x27\x6e][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&" + r"|()\x60\x22\x27\x6f][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x6" + r"9][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x74][^ \t\n;&|()\x60" + r"\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x70][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t" + r"\n;&|()\x60\x22\x27\x69][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x2" + r"7\x72][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x63][^ \t\n;&|()" + r"\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x73][^ \t\n;&|()\x60\x22\x27]*)?|[" + r"^ \t\n;&|()\x60\x22\x27\x65][^ \t\n;&|()\x60\x22\x27]*)?|\x67(?:\x69(?:\x64(" + r"?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|[^ \t\n;&|()\x60\x22" + r"\x27\x64][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x69][^ \t\n;&" + r"|()\x60\x22\x27]*)?|\x68(?:\x6f(?:\x73(?:\x74(?:[^ \t\n;&|()\x60\x22\x27][^ " + r"\t\n;&|()\x60\x22\x27]*)|[^ \t\n;&|()\x60\x22\x27\x74][^ \t\n;&|()\x60\x22\x" + r"27]*)?|[^ \t\n;&|()\x60\x22\x27\x73][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|(" + r")\x60\x22\x27\x6f][^ \t\n;&|()\x60\x22\x27]*)?|\x6d(?:\x61(?:\x63(?:\x68(?:" + r"\x69(?:\x6e(?:\x65(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|[^" + r" \t\n;&|()\x60\x22\x27\x65][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22" r"\x27\x6e][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x69][^ \t\n;&" r"|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x68][^ \t\n;&|()\x60\x22\x27]*)" r"?|[^ \t\n;&|()\x60\x22\x27\x63][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60" @@ -310,60 +310,60 @@ r"(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|\x73(?:[^ \t\n;&|()" r"\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|[^ \t\n;&|()\x60\x22\x27\x2d\x6b\x7" r"3][^ \t\n;&|()\x60\x22\x27]*)))*[ \t]+(?:\x22[^\x22\n]*\x22|\x27[^\x27\n]*\x" - r"27|[^ \t\n;&|()\x60\x22\x27]+)[ \t]+|(?:[^ \t\n;&|()\x60\x22\x27]*/)?(?:xarg" - r"s)(?:[ \t]+(?:(?:-a|--arg-file|-d|--delimiter|-E|-e|-I|-i|-L|-l|-n|--max-arg" - r"s|-P|--max-procs|-s|--max-chars)[ \t]+(?:\x22[^\x22\n]*\x22|\x27[^\x27\n]*\x" - r"27|[^- \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*|-[0-9]+)|-(?:\x2d(?:" - r"\x61(?:\x72(?:\x67(?:\x2d(?:\x66(?:\x69(?:\x6c(?:\x65(?:[^ \t\n;&|()\x60\x22" - r"\x27][^ \t\n;&|()\x60\x22\x27]*)|[^ \t\n;&|()\x60\x22\x27\x65][^ \t\n;&|()\x" - r"60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x6c][^ \t\n;&|()\x60\x22\x27]*)?|[^ " - r"\t\n;&|()\x60\x22\x27\x69][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22" - r"\x27\x66][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x2d][^ \t\n;&" - r"|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x67][^ \t\n;&|()\x60\x22\x27]*)" - r"?|[^ \t\n;&|()\x60\x22\x27\x72][^ \t\n;&|()\x60\x22\x27]*)?|\x64(?:\x65(?:\x" - r"6c(?:\x69(?:\x6d(?:\x69(?:\x74(?:\x65(?:\x72(?:[^ \t\n;&|()\x60\x22\x27][^ " - r"\t\n;&|()\x60\x22\x27]*)|[^ \t\n;&|()\x60\x22\x27\x72][^ \t\n;&|()\x60\x22\x" - r"27]*)?|[^ \t\n;&|()\x60\x22\x27\x65][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|(" - r")\x60\x22\x27\x74][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x69]" - r"[^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x6d][^ \t\n;&|()\x60\x" - r"22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x69][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n" - r";&|()\x60\x22\x27\x6c][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27" - r"\x65][^ \t\n;&|()\x60\x22\x27]*)?|\x6d(?:\x61(?:\x78(?:\x2d(?:\x61(?:\x72(?:" - r"\x67(?:\x73(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|[^ \t\n;&" - r"|()\x60\x22\x27\x73][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x6" - r"7][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x72][^ \t\n;&|()\x60" - r"\x22\x27]*)?|\x63(?:\x68(?:\x61(?:\x72(?:\x73(?:[^ \t\n;&|()\x60\x22\x27][^ " - r"\t\n;&|()\x60\x22\x27]*)|[^ \t\n;&|()\x60\x22\x27\x73][^ \t\n;&|()\x60\x22\x" - r"27]*)?|[^ \t\n;&|()\x60\x22\x27\x72][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|(" - r")\x60\x22\x27\x61][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x68]" - r"[^ \t\n;&|()\x60\x22\x27]*)?|\x70(?:\x72(?:\x6f(?:\x63(?:\x73(?:[^ \t\n;&|()" - r"\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|[^ \t\n;&|()\x60\x22\x27\x73][^ \t" - r"\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x63][^ \t\n;&|()\x60\x22\x2" - r"7]*)?|[^ \t\n;&|()\x60\x22\x27\x6f][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()" - r"\x60\x22\x27\x72][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x61\x" - r"63\x70][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x2d][^ \t\n;&|(" - r")\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x78][^ \t\n;&|()\x60\x22\x27]*)?|" - r"[^ \t\n;&|()\x60\x22\x27\x61][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x" - r"22\x27\x61\x64\x6d][^ \t\n;&|()\x60\x22\x27]*)?|\x45(?:[^ \t\n;&|()\x60\x22" - r"\x27][^ \t\n;&|()\x60\x22\x27]*)|\x49(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|(" - r")\x60\x22\x27]*)|\x4c(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)" - r"|\x50(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|\x61(?:[^ \t\n;" - r"&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|\x64(?:[^ \t\n;&|()\x60\x22\x27" - r"][^ \t\n;&|()\x60\x22\x27]*)|\x65(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x6" - r"0\x22\x27]*)|\x69(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|\x6" - r"c(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|\x6e(?:[^ \t\n;&|()" - r"\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|\x73(?:[^ \t\n;&|()\x60\x22\x27][^ " - r"\t\n;&|()\x60\x22\x27]*)|[^ \t\n;&|()\x60\x22\x27\x2d\x45\x49\x4c\x50\x61\x6" - r"4\x65\x69\x6c\x6e\x73][^ \t\n;&|()\x60\x22\x27]*)))*[ \t]+)*(?:[^ \t\n;&|()" - r"\x60\x22\x27]*/)?(?:(?:(?:sudo|doas|pkexec|run0)(?:[ \t\r\n;&|()\x60]|$)|su(" - r"?:[ \t]+(?:\x22[^\x22\n]*\x22|\x27[^\x27\n]*\x27|[^ \t\n;&|()\x60\x22\x27]+)" - r")*[ \t]+(?:-c[^ \t\n;&|()\x60]*|--command(?:=[^ \t\n;&|()\x60]*)?)(?:[ \t\r" - r"\n;&|()\x60]|$)))", + r"27|[^ \t\n;&|()\x60\x22\x27]+)[ \t]+|(?:[^ \t\n;&|()\x60\x22\x27]*/)?(?:" + r"xargs)(?:[ \t]+(?:(?:-a|--arg-file|-d|--delimiter|-E|-e|-I|-i|-L|-l|-n|--max" + r"-args|-P|--max-procs|-s|--max-chars)[ \t]+(?:\x22[^\x22\n]*\x22|\x27[^\x27\n" + r"]*\x27|[^- \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*|-[0-9]+)|-(?:\x2" + r"d(?:\x61(?:\x72(?:\x67(?:\x2d(?:\x66(?:\x69(?:\x6c(?:\x65(?:[^ \t\n;&|()\x60" + r"\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|[^ \t\n;&|()\x60\x22\x27\x65][^ \t\n;&|" + r"()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x6c][^ \t\n;&|()\x60\x22\x27]*)?" + r"|[^ \t\n;&|()\x60\x22\x27\x69][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60" + r"\x22\x27\x66][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x2d][^ \t" + r"\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x67][^ \t\n;&|()\x60\x22\x2" + r"7]*)?|[^ \t\n;&|()\x60\x22\x27\x72][^ \t\n;&|()\x60\x22\x27]*)?|\x64(?:\x65(" + r"?:\x6c(?:\x69(?:\x6d(?:\x69(?:\x74(?:\x65(?:\x72(?:[^ \t\n;&|()\x60\x22\x27]" + r"[^ \t\n;&|()\x60\x22\x27]*)|[^ \t\n;&|()\x60\x22\x27\x72][^ \t\n;&|()\x60\x2" + r"2\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x65][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;" + r"&|()\x60\x22\x27\x74][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x" + r"69][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x6d][^ \t\n;&|()\x6" + r"0\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x69][^ \t\n;&|()\x60\x22\x27]*)?|[^ " + r"\t\n;&|()\x60\x22\x27\x6c][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22" + r"\x27\x65][^ \t\n;&|()\x60\x22\x27]*)?|\x6d(?:\x61(?:\x78(?:\x2d(?:\x61(?:\x7" + r"2(?:\x67(?:\x73(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|[^ \t" + r"\n;&|()\x60\x22\x27\x73][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x2" + r"7\x67][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x72][^ \t\n;&|()" + r"\x60\x22\x27]*)?|\x63(?:\x68(?:\x61(?:\x72(?:\x73(?:[^ \t\n;&|()\x60\x22\x27" + r"][^ \t\n;&|()\x60\x22\x27]*)|[^ \t\n;&|()\x60\x22\x27\x73][^ \t\n;&|()\x60\x" + r"22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x72][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n" + r";&|()\x60\x22\x27\x61][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27" + r"\x68][^ \t\n;&|()\x60\x22\x27]*)?|\x70(?:\x72(?:\x6f(?:\x63(?:\x73(?:[^ \t\n" + r";&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|[^ \t\n;&|()\x60\x22\x27\x73][" + r"^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x63][^ \t\n;&|()\x60\x2" + r"2\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x6f][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;" + r"&|()\x60\x22\x27\x72][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x" + r"61\x63\x70][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x2d][^ \t\n" + r";&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x60\x22\x27\x78][^ \t\n;&|()\x60\x22\x27]" + r"*)?|[^ \t\n;&|()\x60\x22\x27\x61][^ \t\n;&|()\x60\x22\x27]*)?|[^ \t\n;&|()\x" + r"60\x22\x27\x61\x64\x6d][^ \t\n;&|()\x60\x22\x27]*)?|\x45(?:[^ \t\n;&|()\x60" + r"\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|\x49(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n" + r";&|()\x60\x22\x27]*)|\x4c(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x2" + r"7]*)|\x50(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|\x61(?:[^ " + r"\t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|\x64(?:[^ \t\n;&|()\x60\x2" + r"2\x27][^ \t\n;&|()\x60\x22\x27]*)|\x65(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|" + r"()\x60\x22\x27]*)|\x69(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*" + r")|\x6c(?:[^ \t\n;&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|\x6e(?:[^ \t\n" + r";&|()\x60\x22\x27][^ \t\n;&|()\x60\x22\x27]*)|\x73(?:[^ \t\n;&|()\x60\x22\x2" + r"7][^ \t\n;&|()\x60\x22\x27]*)|[^ \t\n;&|()\x60\x22\x27\x2d\x45\x49\x4c\x50\x" + r"61\x64\x65\x69\x6c\x6e\x73][^ \t\n;&|()\x60\x22\x27]*)))*[ \t]+)*(?:[^ \t\n;" + r"&|()\x60\x22\x27]*/)?(?:(?:(?:sudo|doas|pkexec|run0)(?:[ \t\r\n;&|()\x60]|$)" + r"|su(?:[ \t]+(?:\x22[^\x22\n]*\x22|\x27[^\x27\n]*\x27|[^ \t\n;&|()\x60\x22\x2" + r"7]+))*[ \t]+(?:-c[^ \t\n;&|()\x60]*|--command(?:=[^ \t\n;&|()\x60]*)?)(?:[ " + r"\t\r\n;&|()\x60]|$)))", HISS_REFUSAL, ), ( - r"(?i)(standardsctl|praetorctl)\s+(adopt|conform|bootstrap|needs\s+(scan|repor" - r"t|migrate|epic))\b.*\bdev[/\\]?(\s|$)", + r"(?i)(standardsctl|praetorctl)\s+(adopt|conform|bootstrap|needs\s+(scan|" + r"report|migrate|epic))\b.*\bdev[/\\]?(\s|$)", DEV_01_REFUSAL, ), ] @@ -392,9 +392,9 @@ r"\t]*sed)[ \t]+-n\b(?:[ \t]+[-A-Za-z0-9_./:=@,+~*?]+)*" ) READ_ONLY_VETO = ( - r"[^\t\x20-\x7e]|[\\\x60$(){}<>^!]|--%|%[;&|]|(?i:(?:\A|[^-A-Za-z0-9_])(?:export|de" - r"clare|typeset|set|setenv|alias|function|sal|nal|set-alias|new-alias|doskey|hash)(" - r"?:[^-A-Za-z0-9_]|$))" + r"[^\t\x20-\x7e]|[\\\x60$(){}<>^!]|--%|%[;&|]|(?i:(?:\A|[^-A-Za-z0-9_])(?:export|" + r"declare|typeset|set|setenv|alias|function|sal|nal|set-alias|new-alias|doskey|hash" + r")(?:[^-A-Za-z0-9_]|$))" ) LEFTHOOK_DISABLED = [ diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json index 8dfce64..f77b7f8 100644 --- a/.github/rulesets/main.json +++ b/.github/rulesets/main.json @@ -40,6 +40,13 @@ }, "type": "pull_request" }, + { + "parameters": { + "review_draft_pull_requests": true, + "review_on_push": true + }, + "type": "copilot_code_review" + }, { "parameters": { "required_status_checks": [ @@ -56,4 +63,4 @@ } ], "target": "branch" -} \ No newline at end of file +} diff --git a/.gitignore b/.gitignore index f414882..66cc867 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,7 @@ bin/ /.standards/cache/ /.claude/settings.local.json /.standards-receipt.json +/.codacy/ +.github/instructions/codacy.instructions.md +.cursor/rules/codacy.mdc # END praetor private artifacts diff --git a/.standards.yaml b/.standards.yaml index 0d51f5d..34fae7a 100644 --- a/.standards.yaml +++ b/.standards.yaml @@ -45,17 +45,25 @@ register: exceptions: - rule: HISS-11 path: .github/workflows/release.yml - reason: adoption found the release workflows short of the declared supply chain (SLSA Build Level 3 declared, Level 0 measured; enforce_cosign without a cosign signing step; require_sbom without an SBOM step); raise them as docs/guides/releasing.md in Praetor describes, then remove this entry + reason: adoption found the release workflows short of the declared supply chain (SLSA Build Level 3 declared, Level 0 measured; enforce_cosign without a cosign signing step; require_sbom without an SBOM step); raise them as docs/guides/releasing.md in Praetor describes, then remove this entry (category A, the interval stated in this entry) + entered: "2026-10-22" expires: "2027-01-20" + category: A - rule: declared-linters tool: gitleaks - reason: adoption scaffolds no CI job for gitleaks declared by facet "security:high"; run it in a workflow, or remove this entry + reason: adoption scaffolds no CI job for gitleaks declared by facet "security:high"; run it in a workflow, or remove this entry (category A, the interval stated in this entry) + entered: "2026-10-22" expires: "2027-01-20" + category: A - rule: declared-linters tool: semgrep - reason: adoption scaffolds no CI job for semgrep declared by profile "template-seed", facet "agent:sandboxed"; run it in a workflow, or remove this entry + reason: adoption scaffolds no CI job for semgrep declared by profile "template-seed", facet "agent:sandboxed"; run it in a workflow, or remove this entry (category A, the interval stated in this entry) + entered: "2026-10-22" expires: "2027-01-20" + category: A - rule: declared-linters tool: trivy - reason: adoption scaffolds no CI job for trivy declared by facet "security:high"; run it in a workflow, or remove this entry + reason: adoption scaffolds no CI job for trivy declared by facet "security:high"; run it in a workflow, or remove this entry (category A, the interval stated in this entry) + entered: "2026-10-22" expires: "2027-01-20" + category: A diff --git a/README.md b/README.md index cc2d435..ad84832 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Local checks: `make verify-all` runs the full gate; `make audit` and `standardsctl`, whichever is installed. -![HISS Adopted][praetor-hiss-badge] +[![HISS Adopted][praetor-hiss-badge]][praetor-hiss-agents] Praetor manages this repository's declared governance policy. This managed block records adoption state; it is not a verification certificate. @@ -29,4 +29,5 @@ generated agent context against `AGENTS.md`. 1 recorded infraction; audit forbids growth. [praetor-hiss-badge]: https://img.shields.io/badge/Standards-HISS%20Adopted%20(1%20baselined)-yellow +[praetor-hiss-agents]: https://github.com/golusoris/.github/blob/HEAD/AGENTS.md diff --git a/lefthook.yml b/lefthook.yml index bd75271..98da758 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -12,18 +12,6 @@ # reuse lint at the release line fsfe/reuse-action@v6 runs in CI. # It skips where reuse is not installed and fails on another reuse major. --- -agent-checkpoint-tool: - commands: - checkpoint: - run: >- - sh .config/lefthook/python.sh -B .config/lefthook/scripts/checkpoint.py - --event tool --json --marker -agent-checkpoint-stop: - commands: - checkpoint: - run: >- - sh .config/lefthook/python.sh -B .config/lefthook/scripts/checkpoint.py - --event stop --json --marker pre-commit: parallel: true commands: @@ -76,6 +64,24 @@ pre-push: .config/lefthook/engine.sh security govuln; else echo govulncheck is not installed, install it with go install golang.org/x/vuln/cmd/govulncheck@latest >&2; exit 1; fi + secrets: + run: >- + if command -v gitleaks >/dev/null 2>&1; then sh + .config/lefthook/engine.sh security secrets; else echo gitleaks is not + installed, install it with go install + github.com/zricethezav/gitleaks/v8@v8.30.1 >&2; exit 1; fi + docstrings: + use_stdin: true + run: sh .config/lefthook/engine.sh docstrings check --pre-push + merge-check: + use_stdin: true + run: >- + count=0; ran=0; while read -r _ loid _ roid; do count=$((count + 1)); if + [ $count -gt 64 ]; then echo merge-check: pushed refs exceed limit 64 + >&2; exit 1; fi; if [ x$(echo $loid | tr -d 0) != x ] && [ x$(echo $roid + | tr -d 0) != x ]; then ran=1; sh .config/lefthook/engine.sh merge-check + --range=$roid..$loid || exit $?; fi; done; if [ $ran = 0 ]; then echo no + pushed commit range on stdin, skipping merge-check >&2; fi flavor-audit: run: sh .config/lefthook/engine.sh flavor audit . audit: diff --git a/renovate.json b/renovate.json index 9be0a4e..530f278 100644 --- a/renovate.json +++ b/renovate.json @@ -31,5 +31,22 @@ ], "enabled": false } + ], + "customManagers": [ + { + "currentValueTemplate": "HEAD", + "customType": "regex", + "datasourceTemplate": "git-refs", + "depNameTemplate": "praetor-workflow-engine-pin", + "description": "praetor workflow engine commit pin: track the engine default branch", + "managerFilePatterns": [ + "/^\\.github/workflows/[^/]+\\.ya?ml$/" + ], + "matchStrings": [ + "(?m)^[\\t ]*(PRAETOR_COMMIT)[\\t ]*[:=][\\t ]*[\"']?(?[0-9a-f]{40})[\"']?[\\t ]*(#[^\\r\\n]*)?\\r?$", + "(?m)^[\\t ]*(PRAETOR_REF)[\\t ]*[:=][\\t ]*[\"']?(?[0-9a-fA-F]{7,40})[\"']?[\\t ]*(#[^\\r\\n]*)?\\r?$" + ], + "packageNameTemplate": "https://github.com/cordanaLLM/praetor" + } ] } From 3bd3a7ca18a187a8eb91152ee7e915c7052f4f0a Mon Sep 17 00:00:00 2001 From: lusoris Date: Sun, 8 Nov 2026 14:16:57 +0100 Subject: [PATCH 2/4] fix(governance): restore the checkpoint hooks and name the review bots The re-adopt ran praetorctl adopt without a source root, so the engine could not verify the checkpoint bundle and rewrote lefthook.yml without the agent-checkpoint-tool and agent-checkpoint-stop jobs, while checkpoint.py stayed installed and uncalled. - Re-ran praetorctl adopt --lock-source-root with a checkout of engine 5dfdd28b34d4. It restores both checkpoint jobs in lefthook.yml and refreshes .config/lefthook/scripts/checkpoint.py and common.py, which are now byte-identical to that engine commit. This also clears the two pre-push warnings that both scripts predate this release. - CONTRIBUTING.md said golusoris has no review bot. CodeRabbit, Sourcery and Codacy review pull requests, so the Review section now names them and states that no bot review counts as an approval. Signed-off-by: lusoris --- .config/lefthook/scripts/checkpoint.py | 313 +++++++++++++++++++------ .config/lefthook/scripts/common.py | 219 +++++++++++++++++ CONTRIBUTING.md | 4 +- lefthook.yml | 12 + 4 files changed, 476 insertions(+), 72 deletions(-) diff --git a/.config/lefthook/scripts/checkpoint.py b/.config/lefthook/scripts/checkpoint.py index 8366238..87e71ac 100644 --- a/.config/lefthook/scripts/checkpoint.py +++ b/.config/lefthook/scripts/checkpoint.py @@ -13,7 +13,7 @@ from pathlib import Path from urllib.parse import urlsplit -from common import HookError, run_bounded +from common import HookBudget, HookError, prepare_checkpoint_session, run_bounded MAX_OUTPUT = 1024 * 1024 MAX_PATHS = 10000 @@ -570,7 +570,13 @@ def _pull_request_argv(cfg, branch=None): argv = ["gh", "pr", "list", "--repo", cfg["repository"], "--state", "all"] if branch: argv.extend(("--head", branch)) - return [*argv, "--limit", "200", "--json", "headRefName,state"] + return [ + *argv, + "--limit", + "200", + "--json", + "headRefName,state,headRefOid,baseRefName", + ] def _pull_request_rows(raw, kind): @@ -582,29 +588,49 @@ def _pull_request_rows(raw, kind): not isinstance(row, dict) or not isinstance(row.get("headRefName"), str) or row.get("state") not in {"OPEN", "CLOSED", "MERGED"} + or not isinstance(row.get("headRefOid", ""), str) + or not isinstance(row.get("baseRefName", ""), str) for row in rows ): raise CheckpointError(f"pull request {kind} is malformed") return rows -def _list_pull_requests(root, cfg): - """Return validated states from the single repository-wide PR listing.""" - try: - raw = _run( - _pull_request_argv(cfg), - root, - network=True, - allowed=(0,), - timeout=PULL_REQUEST_TIMEOUT, - ) - rows = _pull_request_rows(raw, "listing") - states = {} - for row in rows: - states.setdefault(row["headRefName"], set()).add(row["state"]) - except (CheckpointError, ValueError, TypeError): - return {} - return states +def _row_marks(row): + """A row's state; a merged row also names the head it carried and the base it landed on.""" + if row["state"] != "MERGED": + return {row["state"]} + return {"MERGED", ("MERGED", row.get("headRefOid", ""), row.get("baseRefName", ""))} + + +def _list_pull_requests(root, cfg, deadline): + """Return validated states from repository PR listing with bounded retry.""" + last_error = "" + backoffs = (1, 2) + for attempt in range(3): + if time.monotonic() >= deadline: + break + try: + raw = _run( + _pull_request_argv(cfg), + root, + network=True, + allowed=(0,), + timeout=PULL_REQUEST_TIMEOUT, + ) + rows = _pull_request_rows(raw, "listing") + states = {} + for row in rows: + states.setdefault(row["headRefName"], set()).update(_row_marks(row)) + return states, "" + except (CheckpointError, ValueError, TypeError) as error: + last_error = str(error) + if attempt < len(backoffs): + delay = backoffs[attempt] + if time.monotonic() + delay > deadline: + break + time.sleep(delay) + return None, last_error or "worktree check budget exhausted" def _probe_one_pull_request(root, cfg, branch): @@ -617,7 +643,9 @@ def _probe_one_pull_request(root, cfg, branch): timeout=PULL_REQUEST_TIMEOUT, ) rows = _pull_request_rows(raw, "probe") - return {row["state"] for row in rows if row["headRefName"] == branch} + return set().union( + *(_row_marks(row) for row in rows if row["headRefName"] == branch) + ) def _probe_pull_requests(root, cfg, branches, states, deadline): @@ -641,48 +669,106 @@ def _pull_request_states(root, cfg, branches, deadline): if not branches: return {}, "" if time.monotonic() >= deadline: - return {}, "worktree check budget exhausted" - states = _list_pull_requests(root, cfg) + return None, "worktree check budget exhausted" + states, error = _list_pull_requests(root, cfg, deadline) + if error: + return None, error return _probe_pull_requests(root, cfg, branches, states, deadline), "" -def _is_gate_scratch(resolved, root_path): - """Whether resolved path lies under checkout's .standards/worktrees directory.""" +GATE_SCRATCH_RE = re.compile(r"gate-([1-9][0-9]{0,9})-[0-9]{1,20}") +MAX_GATE_CMDLINE = 16 * 1024 + + +def _gate_scratch_pid(resolved, worktree_paths): + """Recognize a gate path beneath any listed checkout of this repository.""" + for candidate in (resolved, *resolved.parents)[:256]: + match = GATE_SCRATCH_RE.fullmatch(candidate.name) + if match is None: + continue + parent = candidate.parent + if ( + parent.name == "worktrees" + and parent.parent.name == ".standards" + and parent.parent.parent in worktree_paths + ): + return int(match[1]) + return None + + +def _live_gate_pid(pid): + """Require bounded /proc command identity; a reused or unreadable PID fails closed.""" try: - scratch = (root_path / ".standards" / "worktrees").resolve() - return resolved == scratch or resolved.is_relative_to(scratch) - except (ValueError, AttributeError): + with Path(f"/proc/{pid}/cmdline").open("rb") as stream: + raw = stream.read(MAX_GATE_CMDLINE + 1) + except OSError: + return False + if not raw or len(raw) > MAX_GATE_CMDLINE: return False + argv = raw.rstrip(b"\0").split(b"\0") + return ( + len(argv) >= 2 + and Path(os.fsdecode(argv[0])).name == "praetorctl" + and argv[1] == b"gate" + ) + + +def _is_gate_scratch(resolved, worktree_paths): + """Only a scratch checkout whose owning praetorctl gate is live can be skipped.""" + pid = _gate_scratch_pid(resolved, worktree_paths) + return pid is not None and _live_gate_pid(pid) + + +def _existing_worktree(entry): + """Resolve only present checkout directories; broken paths remain unselected.""" + try: + path = Path(entry.get("path", "")).resolve(strict=True) + return path if path.is_dir() else None + except (OSError, RuntimeError): + return None + + +def _unfinished_candidate(entry, path, *, common_dir, open_text, now, owned, paths): + """Keep dead gate owners independently of session ownership and recent activity.""" + if _is_gate_scratch(path, paths): + return False + pid = _gate_scratch_pid(path, paths) + if pid is not None: + entry["gate_reason"] = f"gate owner pid {pid} is not a live praetorctl gate run" + return True + if not _worktree_recent(path, common_dir, now) or ( + owned is not None and str(path) not in owned + ): + return False + return not _named_worktree(open_text, entry) def _recent_unnamed_worktrees(root, common_dir, open_text, now, owned=None): - """Return bounded listed worktrees that are recent, present, and unnamed.""" + """Select session worktrees, retaining abandoned gate scratch regardless of ownership.""" root_path = Path( _git(root, "rev-parse", "--show-toplevel", timeout=WORKTREE_COMMAND_TIMEOUT) .decode() .strip() ).resolve() + entries = [(entry, _existing_worktree(entry)) for entry in _listed_worktrees(root)] + paths = {path for _entry, path in entries if path is not None} candidates = [] - for entry in _listed_worktrees(root): - path = Path(entry.get("path", "")) - try: - resolved = path.resolve(strict=True) - except (OSError, RuntimeError): - continue - if ( - resolved == root_path - or entry.get("bare") - or entry.get("prunable") - or not resolved.is_dir() - or _is_gate_scratch(resolved, root_path) - or not _worktree_recent(resolved, common_dir, now) - ): + for entry, resolved in entries: + if resolved is None or resolved == root_path: continue - if owned is not None and str(resolved) not in owned: + if entry.get("bare") or entry.get("prunable"): continue entry["path"] = str(resolved) entry["branch"] = entry.get("branch", "").removeprefix("refs/heads/") - if not _named_worktree(open_text, entry): + if _unfinished_candidate( + entry, + resolved, + common_dir=common_dir, + open_text=open_text, + now=now, + owned=owned, + paths=paths, + ): candidates.append((entry, resolved)) return candidates @@ -717,14 +803,15 @@ def _public_change_reason(path): return [str(error)] -def _content_in_base(path, cfg): +def _content_in_base(path, cfg, base=None): """Whether the worktree's net change since its merge base is already in the remote base. A branch that landed through a squash or a train has neither a remote copy of its commits nor a pull request of its own head, yet nothing of it is unfinished. Its reversed patch then - applies to the base. Read-only: the index lives in a temporary folder outside the repository. + applies to the base: the configured one, or the base a merged pull request names. Read-only: + the index lives in a temporary folder outside the repository. """ - remote, base = cfg.get("remote") or "origin", cfg.get("base") or "main" + remote, base = cfg.get("remote") or "origin", base or cfg.get("base") or "main" base_ref = f"refs/remotes/{remote}/{base}" if not _remote_ref_exists(path, base_ref): return False @@ -757,41 +844,75 @@ def _landed(path, cfg): return False +def _landed_by_pull_request(path, cfg, marks): + """Whether a merged pull request carried this HEAD, or put its change into the PR's base. + + Pull requests land on more bases than the configured one (an operations repository lands on + its role branch), and that base may since have changed the same lines, so no reversed patch + applies. A base name outside REF_NAME never reaches git. Fails closed like _landed. + """ + merged = [mark[1:] for mark in marks if isinstance(mark, tuple)] + if not merged: + return False + try: + head = _git(path, "rev-parse", "HEAD", timeout=WORKTREE_COMMAND_TIMEOUT) + if head.decode().strip() in {oid for oid, _base in merged}: + return True + return any( + REF_NAME.fullmatch(base) + and ".." not in base + and "//" not in base + and _content_in_base(path, cfg, base) + for _oid, base in merged + ) + except (CheckpointError, ValueError, OSError): + return False + + def _worktree_unpublished_count(path): """Count HEAD commits unreachable from every remote, using the canonical criterion.""" return _rev_list_count(path, "--remotes") def _worktree_pr_needed(path, cfg, reasons): - """Check unpublished commits and base ahead; append reasons and return needs_pr.""" + """Check unpublished commits and base ahead; append reasons and return the PR check kind. + + "pushed": the branch is pushed and ahead of base, so it needs an open or merged pull + request. "landed": its commits are on no remote and nothing else is unfinished, so a merged + pull request of its head may show it landed (_landed_by_pull_request). "": no PR check. + """ try: count = _worktree_unpublished_count(path) except (CheckpointError, ValueError, OSError) as error: reasons.append(f"commit state not verified ({error})") - return False + return "" + enabled = bool(cfg.get("enabled") and cfg.get("repository")) if count: + kind = "landed" if enabled and not reasons else "" reasons.append(f"{count} commit(s) not on any remote") - return False - if not cfg.get("enabled") or not cfg.get("repository"): - return False + return kind + if not enabled: + return "" try: - return _worktree_base_ahead(path, cfg) + return "pushed" if _worktree_base_ahead(path, cfg) else "" except (CheckpointError, ValueError, OSError) as error: reasons.append(f"pull request state not verified ({error})") - return False + return "" def _judge_worktree(root, cfg, entry, path, deadline): - """Return one worktree's unfinished reasons and whether its PR needs checking.""" + """Return one worktree's unfinished reasons and the kind of PR check it needs.""" if time.monotonic() >= deadline: return ( entry, ["pull request state not verified (worktree check budget exhausted)"], - False, + "", ) reasons = _public_change_reason(path) + if entry.get("gate_reason"): + reasons.append(entry["gate_reason"]) if not reasons and _landed(path, cfg): - return entry, [], False + return entry, [], "" needs_pr = _worktree_pr_needed(path, cfg, reasons) return entry, reasons, needs_pr @@ -831,17 +952,45 @@ def work(): def _judge_worktree_candidates(root, cfg, candidates, deadline): - """Separate unfinished records from branches requiring bounded PR state reads.""" + """Separate unfinished records from branches requiring bounded PR state reads. + + Pushed candidates come first, so landed ones never take the three explicit-head probes a + pushed branch outside the listing needs; a detached HEAD names no branch to look up. + """ jobs = [(cfg, entry, path, deadline) for entry, path in candidates] results = _ordered_map(lambda job: _judge_worktree(root, *job), jobs) records = [(entry, reasons) for entry, reasons, _ in results] pr_candidates = [] for entry, _reasons, needs_pr in results: - if needs_pr: - pr_candidates.append((entry, _reasons)) + if needs_pr == "pushed" or (needs_pr == "landed" and entry.get("branch")): + pr_candidates.append((entry, _reasons, needs_pr)) + pr_candidates.sort(key=lambda item: item[2] != "pushed") return records, pr_candidates +def _apply_pull_request_states(cfg, pr_candidates, states): + """Judge each candidate by its branch's PR states; return the unverified pushed count. + + A landed candidate keeps its unpublished-commit reason unless a merged pull request shows + it landed; a failed lookup leaves that reason as it is. + """ + pushed = [item for item in pr_candidates if item[2] == "pushed"] + if states is None: + return len(pushed) + for entry, reasons, kind in pr_candidates: + state = states.get(entry["branch"]) + if kind == "landed": + if state and _landed_by_pull_request(Path(entry["path"]), cfg, state): + reasons.clear() + elif state is None or "UNVERIFIED" in state: + reasons.append("pull request state not verified") + elif not state.intersection({"OPEN", "MERGED"}): + reasons.append( + "pushed branch ahead of base has no open or merged pull request" + ) + return 0 + + def _git_common_dir(root): """Resolve repository common Git directory.""" raw = ( @@ -859,7 +1008,7 @@ def _git_common_dir(root): def _unfinished_worktree_records(root, cfg): - """Observe only recent, unnamed linked worktrees; return records and optional skip note.""" + """Observe recent worktrees; return records, skip note, unverified count.""" deadline = time.monotonic() + WORKTREE_CHECK_BUDGET common_dir = _git_common_dir(root) owned = _session_owned_worktrees(common_dir) @@ -870,26 +1019,19 @@ def _unfinished_worktree_records(root, cfg): states, skipped = _pull_request_states( root, cfg, [item[0]["branch"] for item in pr_candidates], deadline ) - for entry, reasons in pr_candidates: - state = states.get(entry["branch"]) - if state is None or "UNVERIFIED" in state: - reasons.append("pull request state not verified") - elif not state.intersection({"OPEN", "MERGED"}): - reasons.append( - "pushed branch ahead of base has no open or merged pull request" - ) + unverified_pushed = _apply_pull_request_states(cfg, pr_candidates, states) unfinished = [(entry, reasons) for entry, reasons in records if reasons] if owned is None: for _entry, reasons in unfinished: reasons.append(OWNERSHIP_UNKNOWN_REASON) - return unfinished, skipped + return unfinished, skipped, unverified_pushed def _add_unfinished_worktrees(root, cfg, result): """Append bounded stop actions for every recent unnamed worktree with unfinished work.""" if result.get("deferred"): return - records, skipped = _unfinished_worktree_records(root, cfg) + records, skipped, unverified_pushed = _unfinished_worktree_records(root, cfg) result["unfinished_worktrees"] = [ {"path": item[0]["path"], "branch": item[0]["branch"], "reasons": item[1]} for item in records[:MAX_UNFINISHED_WORKTREES] @@ -911,6 +1053,11 @@ def _add_unfinished_worktrees(root, cfg, result): result["actions"].append( f"Unfinished worktrees: and {more} more; name them in .workingdir/OPEN.md" ) + if unverified_pushed: + result["actions"].append( + f"Pull request lookup failed ({skipped}): {unverified_pushed} pushed " + "branch(es) unverified; retry or name in .workingdir/OPEN.md" + ) def _branch(root, timeout=None): @@ -1551,6 +1698,27 @@ def inspect_checkpoint(root: Path, event: str, include_paths: bool = False) -> d return result +def _prepare_native_session(args): + """Bridge Go to the same bounded session implementation used by the Python adapter.""" + try: + raw = sys.stdin.buffer.read(MAX_OUTPUT + 1) + if len(raw) > MAX_OUTPUT: + raise ValueError("oversized checkpoint session input") + payload = json.loads(raw) + if not isinstance(payload, dict): + raise TypeError("checkpoint session input must be an object") + session_hash = prepare_checkpoint_session( + payload, args.root.resolve(), HookBudget(5), args.event == "stop" + ) + print( + "PRAETOR_CHECKPOINT_SESSION=" + session_hash + ) # caveman:not-applicable structured-protocol + return 0 + except (HookError, OSError, TypeError, ValueError) as error: + print("checkpoint session: " + str(error)[:1000], file=sys.stderr) + return 1 + + def main(argv=None): parser = argparse.ArgumentParser() parser.add_argument("--event", choices=("tool", "stop"), required=True) @@ -1558,7 +1726,10 @@ def main(argv=None): parser.add_argument("--json", action="store_true") parser.add_argument("--marker", action="store_true") parser.add_argument("--include-paths", action="store_true") + parser.add_argument("--prepare-session", action="store_true") args = parser.parse_args(argv) + if args.prepare_session: + return _prepare_native_session(args) result = inspect_checkpoint(args.root.resolve(), args.event, args.include_paths) line = json.dumps(result, sort_keys=True) print( diff --git a/.config/lefthook/scripts/common.py b/.config/lefthook/scripts/common.py index 77f5b7f..1db403e 100644 --- a/.config/lefthook/scripts/common.py +++ b/.config/lefthook/scripts/common.py @@ -1,8 +1,11 @@ """Bounded process execution and isolated Git snapshots for local checks.""" import contextlib +import hashlib +import json import math import os +import re import selectors import shutil import signal @@ -694,3 +697,219 @@ def present_files(directory, names): if path.is_file(): files.append(name) return files + + +# Shared native checkpoint session records: Python adapters and the Go hook caller. +SESSION_ENV = "PRAETOR_CHECKPOINT_SESSION" +SESSION_INPUT_LIMIT = 1024 * 1024 +MAX_WORKTREES = 512 +MAX_RECORD_BYTES = 512 * 1024 + + +def extract_session_id(payload): + """Read session ID from agent payload without guessing.""" + if not isinstance(payload, dict): + return None + session_id = payload.get("session_id") + if isinstance(session_id, str) and session_id.strip(): + return session_id.strip() + conv_id = payload.get("conversationId") + if isinstance(conv_id, str) and conv_id.strip(): + return conv_id.strip() + return None + + +def session_hash_for(session_id): + """Return first 16 hex chars of SHA256 of session ID. Never store raw ID.""" + return hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:16] + + +def _git_common_dir(root, budget): + """Resolve repository common Git directory.""" + raw = run_bounded( + ["git", "rev-parse", "--path-format=absolute", "--git-common-dir"], + cwd=root, + timeout=budget.timeout(SESSION_GIT_TIMEOUT), + max_output=SESSION_GIT_OUTPUT, + grace=SESSION_GIT_GRACE, + ) + return Path(raw.decode().strip()).resolve() + + +def _listed_worktrees(root, budget): + """Read bounded NUL-delimited worktree listing.""" + raw = run_bounded( + ["git", "worktree", "list", "--porcelain", "-z"], + cwd=root, + timeout=budget.timeout(SESSION_GIT_TIMEOUT), + max_output=SESSION_INPUT_LIMIT, + grace=SESSION_GIT_GRACE, + ) + entries, entry = [], {} + for part in raw.split(b"\0"): + field = os.fsdecode(part) + if not field: + if entry and "path" in entry: + entries.append(entry) + entry = {} + if len(entries) >= MAX_WORKTREES: + break + continue + key, sep, val = field.partition(" ") + if key == "worktree" and sep: + entry["path"] = val + if entry and "path" in entry and len(entries) < MAX_WORKTREES: + entries.append(entry) + return entries + + +MAX_EXTRACT_NODES = 256 + + +def _extract_strings(data): + """Extract string values iteratively from tool input under a scalar bound.""" + result = [] + stack = [data] + for _ in range(MAX_EXTRACT_NODES): + if not stack: + break + item = stack.pop() + if isinstance(item, str): + result.append(item) + elif isinstance(item, dict): + stack.extend(item.values()) + elif isinstance(item, (list, tuple)): + stack.extend(item) + return result + + +def _extract_tool_info(payload): + """Extract tool name and tool input from payload.""" + tool_name = payload.get("tool_name") + tool_input = payload.get("tool_input") + if not tool_name and isinstance(payload.get("toolCall"), dict): + call = payload["toolCall"] + tool_name = call.get("name") + tool_input = call.get("args") + return str(tool_name or ""), tool_input + + +def _path_matches(resolved_path_str, text): + """Compare literal paths in tool text after resolving both filesystem spellings.""" + # Keep quoted paths with spaces intact; also find absolute operands embedded + # in shell commands or scripts. Never execute or expand the command text. + pattern = r"""(?()]+)""" + candidates = [ + text, + *(match.group().strip("\"'") for match in re.finditer(pattern, text)), + ] + for candidate in candidates: + if not Path(candidate).is_absolute(): + continue + try: + resolved_relative_to(candidate, resolved_path_str) + return True + except (OSError, RuntimeError, ValueError): + continue + return False + + +def _is_worktree_touched(resolved, tool_name, tool_input, strings): + """Whether worktree was touched by tool invocation.""" + if tool_name == "EnterWorktree" and isinstance(tool_input, dict): + target = tool_input.get("name") or tool_input.get("path") + if isinstance(target, str) and target.strip() in (resolved.name, str(resolved)): + return True + path_str = str(resolved) + return any(_path_matches(path_str, s) for s in strings) + + +def _read_session_record(record_file): + """Read existing worktree paths from session record file.""" + try: + with record_file.open("rb") as stream: + raw = stream.read(MAX_RECORD_BYTES + 1) + if len(raw) > MAX_RECORD_BYTES: + return set() + data = json.loads(raw) + if isinstance(data, list): + return {str(Path(p).resolve()) for p in data if isinstance(p, str)} + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + pass + return set() + + +def _write_session_record(sessions_dir, record_file, paths): + """Atomically write sorted bounded worktree paths to session record.""" + sessions_dir.mkdir(parents=True, exist_ok=True) + sorted_paths = sorted(paths)[:MAX_WORKTREES] + payload = json.dumps(sorted_paths).encode("utf-8") + if len(payload) > MAX_RECORD_BYTES: + raise ValueError("checkpoint session record exceeds byte bound") + tmp = None + try: + with tempfile.NamedTemporaryFile(dir=sessions_dir, delete=False) as handle: + tmp = Path(handle.name) + handle.write(payload) + os.replace(tmp, record_file) + tmp = None + finally: + if tmp is not None: + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + + +def ensure_session_record(root, session_hash, budget): + """Ensure session record file exists before running stop checkpoint.""" + common_dir = _git_common_dir(root, budget) + sessions_dir = common_dir / "praetor-checkpoint" / "sessions" + record_file = sessions_dir / f"{session_hash}.json" + if not record_file.is_file(): + _write_session_record(sessions_dir, record_file, set()) + + +def record_tool_worktrees(payload, root, budget): + """Record any worktree paths touched by tool event.""" + session_id = extract_session_id(payload) + if not session_id: + return + session_hash = session_hash_for(session_id) + tool_name, tool_input = _extract_tool_info(payload) + if not tool_input: + return + strings = _extract_strings(tool_input) + common_dir = _git_common_dir(root, budget) + sessions_dir = common_dir / "praetor-checkpoint" / "sessions" + record_file = sessions_dir / f"{session_hash}.json" + + worktrees = _listed_worktrees(root, budget) + touched = set() + for entry in worktrees: + try: + resolved = Path(entry["path"]).resolve(strict=True) + except (OSError, RuntimeError): + continue + if resolved == root: + continue + if _is_worktree_touched(resolved, tool_name, tool_input, strings): + touched.add(str(resolved)) + if not touched and record_file.is_file(): + return + existing = _read_session_record(record_file) if record_file.is_file() else set() + if touched - existing or not record_file.is_file(): + _write_session_record(sessions_dir, record_file, existing | touched) + + +def prepare_checkpoint_session(payload, root, budget, stopping): + """Prepare one shared ownership record and return its hashed identity.""" + session_id = extract_session_id(payload) + if not session_id: + return "" + session_hash = session_hash_for(session_id) + if stopping: + ensure_session_record(root, session_hash, budget) + else: + record_tool_worktrees(payload, root, budget) + return session_hash diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6526c36..d31d882 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,9 @@ This file covers every golusoris repository that ships no `CONTRIBUTING.md` of i ## Review -golusoris has a single maintainer and no review bot. Pull requests merge once the required checks pass and need no approving review; the `praetor-main-protection` ruleset requires zero approvals, signed commits and linear history. This holds until a second maintainer joins. +golusoris has a single maintainer. Pull requests merge once the required checks pass and need no approving review; the `praetor-main-protection` ruleset requires zero approvals, signed commits and linear history. This holds until a second maintainer joins. + +Review bots, such as CodeRabbit, Sourcery and Codacy, comment on pull requests. Each comment is fixed or answered before the pull request merges, and no bot review counts as an approval. ## Commits diff --git a/lefthook.yml b/lefthook.yml index 98da758..d7effd8 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -12,6 +12,18 @@ # reuse lint at the release line fsfe/reuse-action@v6 runs in CI. # It skips where reuse is not installed and fails on another reuse major. --- +agent-checkpoint-tool: + commands: + checkpoint: + run: >- + sh .config/lefthook/python.sh -B .config/lefthook/scripts/checkpoint.py + --event tool --json --marker +agent-checkpoint-stop: + commands: + checkpoint: + run: >- + sh .config/lefthook/python.sh -B .config/lefthook/scripts/checkpoint.py + --event stop --json --marker pre-commit: parallel: true commands: From 52d653fa46b598cd5dff6c52fd7a545d077720aa Mon Sep 17 00:00:00 2001 From: lusoris Date: Sun, 8 Nov 2026 14:24:07 +0100 Subject: [PATCH 3/4] docs(contributing): say that review threads must be resolved The praetor-main-protection ruleset sets required_review_thread_resolution, so a pull request stays blocked after its checks pass until every review thread is resolved. Fixing or answering a comment does not resolve its thread, and the Review section did not say so. Signed-off-by: lusoris --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d31d882..6526909 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ This file covers every golusoris repository that ships no `CONTRIBUTING.md` of i golusoris has a single maintainer. Pull requests merge once the required checks pass and need no approving review; the `praetor-main-protection` ruleset requires zero approvals, signed commits and linear history. This holds until a second maintainer joins. -Review bots, such as CodeRabbit, Sourcery and Codacy, comment on pull requests. Each comment is fixed or answered before the pull request merges, and no bot review counts as an approval. +Review bots, such as CodeRabbit, Sourcery and Codacy, comment on pull requests. Each comment is fixed or answered, and its thread resolved, before the pull request merges: the ruleset blocks merging while a review thread is unresolved. No bot review counts as an approval. ## Commits From ad4cf95bf294c96bd1f722e4a5761244fbdfdeb7 Mon Sep 17 00:00:00 2001 From: lusoris Date: Sun, 8 Nov 2026 14:36:58 +0100 Subject: [PATCH 4/4] docs(contributing): limit thread resolution to review threads A bot's top-level walkthrough comment has no review thread, so the rule now asks for each review thread to be resolved rather than the thread of every comment. Signed-off-by: lusoris --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6526909..c94cd96 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ This file covers every golusoris repository that ships no `CONTRIBUTING.md` of i golusoris has a single maintainer. Pull requests merge once the required checks pass and need no approving review; the `praetor-main-protection` ruleset requires zero approvals, signed commits and linear history. This holds until a second maintainer joins. -Review bots, such as CodeRabbit, Sourcery and Codacy, comment on pull requests. Each comment is fixed or answered, and its thread resolved, before the pull request merges: the ruleset blocks merging while a review thread is unresolved. No bot review counts as an approval. +Review bots, such as CodeRabbit, Sourcery and Codacy, comment on pull requests. Each comment is fixed or answered, and each review thread is resolved, before the pull request merges: the ruleset blocks merging while a review thread is unresolved. A top-level comment, such as a bot's walkthrough, has no thread to resolve. No bot review counts as an approval. ## Commits