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/.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/.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/CONTRIBUTING.md b/CONTRIBUTING.md index 6526c36..c94cd96 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, 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 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..d7effd8 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -76,6 +76,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" + } ] }