riskrails 4.0.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +22 -13
  5. package/bin/menu.js +116 -0
  6. package/bin/riskrails.js +119 -20
  7. package/hooks/AGENTS.md +0 -6
  8. package/hooks/CLAUDE.md +0 -6
  9. package/hooks/guard/dialog.py +14 -2
  10. package/hooks/guard/paths.py +48 -1406
  11. package/hooks/guard/paths_base.py +59 -0
  12. package/hooks/guard/paths_bash.py +176 -0
  13. package/hooks/guard/paths_classify.py +205 -0
  14. package/hooks/guard/paths_fs.py +137 -0
  15. package/hooks/guard/paths_human.py +105 -0
  16. package/hooks/guard/paths_interp.py +162 -0
  17. package/hooks/guard/paths_movers.py +393 -0
  18. package/hooks/guard/paths_shell.py +288 -0
  19. package/package.json +1 -1
  20. package/skills/AGENTS.md +0 -6
  21. package/skills/CLAUDE.md +0 -6
  22. package/skills/ai-init/scaffold-ai.sh +2 -1
  23. package/skills/ai-init/templates/.ai/policies/safety.md +4 -3
  24. package/skills/ai-init/templates/gitignore.snippet +2 -0
  25. package/skills/ai-task/memory_cleanup.py +213 -0
  26. package/skills/ai-task/memory_compact.py +25 -0
  27. package/skills/ai-task/memory_config.py +120 -0
  28. package/skills/ai-task/memory_export.py +246 -0
  29. package/skills/ai-task/memory_format.py +7 -3
  30. package/skills/ai-task/memory_hook.py +172 -0
  31. package/skills/ai-task/memory_io.py +122 -0
  32. package/skills/ai-task/memory_migrate.py +187 -0
  33. package/skills/ai-task/memory_packages.py +83 -0
  34. package/skills/ai-task/memory_risks.py +84 -0
  35. package/skills/ai-task/memory_status.py +76 -0
  36. package/skills/ai-task/memory_sync.py +20 -1481
  37. package/skills/ai-task/memory_verify.py +316 -0
  38. package/skills/ai-task/rr_state/cli.py +2 -1
  39. package/skills/ai-task/rr_state/close.py +2 -2
  40. package/skills/ai-task/rr_state/dialog_cmd.py +183 -6
  41. package/skills/ai-task/rr_state/labels.py +14 -2
  42. package/skills/ai-task/rr_state/memory_cmd.py +91 -68
  43. package/skills/project-update/history/blobs/8ec171f0fbdf38cc6996d496f01491fd1f21f04a435a118ad979b127a8be06f1 +85 -0
  44. package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
  45. package/skills/project-update/history/index.json +4 -2
  46. package/skills/project-update/rr_update/__init__.py +1 -0
  47. package/skills/project-update/rr_update/apply.py +188 -0
  48. package/skills/project-update/rr_update/blocks.py +38 -0
  49. package/skills/project-update/rr_update/build.py +96 -0
  50. package/skills/project-update/rr_update/cleanup.py +194 -0
  51. package/skills/project-update/rr_update/cli.py +102 -0
  52. package/skills/project-update/rr_update/files.py +106 -0
  53. package/skills/project-update/rr_update/gate.py +60 -0
  54. package/skills/project-update/rr_update/hints.py +128 -0
  55. package/skills/project-update/rr_update/history.py +117 -0
  56. package/skills/project-update/rr_update/kept.py +120 -0
  57. package/skills/project-update/rr_update/paths.py +108 -0
  58. package/skills/project-update/rr_update/plan.py +123 -0
  59. package/skills/project-update/rr_update/report.py +51 -0
  60. package/skills/project-update/rr_update/rules.py +178 -0
  61. package/skills/project-update/rr_update/shared.py +74 -0
  62. package/skills/project-update/rr_update/siblings.py +37 -0
  63. package/skills/project-update/rr_update/weights.py +171 -0
  64. package/skills/project-update/update.py +8 -1682
  65. package/skills/rr-setup/SKILL.md +79 -21
  66. package/skills/rr-setup/scaffold_ai.py +8 -2
  67. package/skills/rr-setup/setup.py +371 -4
@@ -0,0 +1,105 @@
1
+ """The human-only gates: the --confirm-delete rule (confirm_delete) and the state.py commands an agent
2
+ may not run (approve, profile set, the --by forms, the dialog recorder), with their patterns
3
+ (dialog_consent).
4
+
5
+ R16: shell (they read the file system, the clock or a subprocess): dialog_consent; every other name here is core.
6
+ Moved from paths.py in WP5 (small-oop-modules), unchanged.
7
+ """
8
+ import os
9
+ from guard.common import Deny, b_bytes, exists, herestring_lines
10
+ from guard.paths_base import END, Q, VAR, WHY_CONFIRM_DELETE
11
+
12
+
13
+ # The --confirm-delete rule, and --confirm-cleanup (the same human gate; any argparse prefix).
14
+ SEP = "(([^|;&]|[<>]&|&>)*[[:space:]])?" + Q
15
+ CONFIRM_DEL = "d(e(l(e(t(e)?)?)?)?)?"
16
+ CONFIRM_CLEAN = "c(l(e(a(n(u(p)?)?)?)?)?)?"
17
+ CONFIRM = ("--co(n(f(i(r(m(-(" + CONFIRM_DEL + "|" + CONFIRM_CLEAN + ")?)?)?)?)?)?)?"
18
+ "(=|[[:space:]]|['\"]|$)")
19
+ APPLY = "--ap(p(l(y)?)?)?" + END
20
+ HEAD = "(^|[[:space:]'\"/|;&(`])(update\\.py|-[A-Za-z]*m[[:space:]]*" + Q + "update|" + VAR + ")" + Q + "[[:space:]]"
21
+ CONFIRM_RE = HEAD + SEP + CONFIRM + SEP + APPLY + "|" + HEAD + SEP + APPLY + SEP + CONFIRM
22
+
23
+ # The gate rules.
24
+ ARGS = "([[:space:]]+--r[a-z]*(=|[[:space:]]+)(\"[^\"]*\"|'[^']*'|[^[:space:]]+))*"
25
+ VERB = "([[:space:]]+--)?[[:space:]]+" + Q + "approve" + END
26
+ BOUND = "(^|[|;&([:space:]`])"
27
+ APPROVE_RE = (BOUND + "(python3?[[:space:]]+)?" + Q + "[^[:space:]'\"]*state\\.py" + Q + ARGS + VERB + "|"
28
+ + BOUND + "(python3?[[:space:]]+)?" + Q + VAR + Q + ARGS + VERB)
29
+ # R36.11: the other human-only state.py commands — `profile set` (dialog on only)
30
+ # and the --by forms of verify, review and reject (dialog on or off) — run from an
31
+ # agent session only as the next live pick, like approve. `start --mode light --by`
32
+ # is denied with the dialog off only: `light` is the one --mode value that can
33
+ # never raise a mode; with the dialog on it reaches state.py, which opens the
34
+ # lowering decision (review H1, T-2026-10-28-010); any other `start --mode X --by`
35
+ # reaches state.py too (T-2026-10-30-0758 Q1, Q2).
36
+ PROFILE_SET_VERB = "([[:space:]]+--)?[[:space:]]+" + Q + "profile" + Q + "[[:space:]]+" + Q + "set" + END
37
+ PROFILE_SET_RE = (BOUND + "(python3?[[:space:]]+)?" + Q + "[^[:space:]'\"]*state\\.py" + Q + ARGS + PROFILE_SET_VERB
38
+ + "|" + BOUND + "(python3?[[:space:]]+)?" + Q + VAR + Q + ARGS + PROFILE_SET_VERB)
39
+ BY_TOK = Q + "--by?" + Q + "(=|[[:space:]]|$)"
40
+ MODE_LIGHT = Q + "--m(o(d(e)?)?)?" + Q + "(=|[[:space:]]+)" + Q + "light" + END
41
+ SKIP = "([^|;&`]*[[:space:]])?"
42
+ HUMAN_BY_VERB = ("([[:space:]]+--)?[[:space:]]+" + Q + "(verify|review|reject)" + END + SKIP + BY_TOK)
43
+ START_LIGHT_VERB = ("([[:space:]]+--)?[[:space:]]+" + Q + "start" + END + SKIP + "("
44
+ + MODE_LIGHT + SKIP + BY_TOK + "|" + BY_TOK + SKIP + MODE_LIGHT + ")")
45
+
46
+
47
+ def state_verb_re(verb):
48
+ """The state.py command whose verb part is `verb`, as an ERE: by path or by a variable."""
49
+ return (BOUND + "(python3?[[:space:]]+)?" + Q + "[^[:space:]'\"]*state\\.py" + Q + ARGS
50
+ + "(" + verb + ")|" + BOUND + "(python3?[[:space:]]+)?" + Q + VAR + Q + ARGS + "(" + verb + ")")
51
+
52
+
53
+ HUMAN_BY_RE = state_verb_re(HUMAN_BY_VERB)
54
+ START_LIGHT_RE = state_verb_re(START_LIGHT_VERB)
55
+
56
+ # R36.11: running the dialog's recorder by hand would forge the human's answer.
57
+ # dialog-gate.py in command position — the program itself, or the script a
58
+ # python runs — after a separator, a quote, exec/env/command/nohup/time/sudo
59
+ # and their words; naming it as an argument (grep, git diff, chmod) is not running it.
60
+ DIALOG_GATE_RE = ("(^|[|;&(`{'\"]|\\$\\()[[:space:]]*((exec|env|command|nohup|time|sudo)([[:space:]]+[^[:space:]|;&]+)*"
61
+ "[[:space:]]+)?(" + Q + "[^[:space:]'\"|;&]*python[0-9.]*" + Q
62
+ + "([[:space:]]+-[^[:space:]]+)*[[:space:]]+)?" + Q + "[^[:space:]'\"|;&]*dialog-gate\\.py" + Q
63
+ + "([[:space:]|;&)<>]|$)")
64
+
65
+
66
+ # -------------------------------------------------------------- the check
67
+
68
+ def confirm_delete(self, cmd):
69
+ if "--co" not in cmd:
70
+ return
71
+ joined = cmd.replace("\\\n", " ")
72
+ for line in herestring_lines(joined):
73
+ if self.loc.length(line) <= 16384:
74
+ continue
75
+ if "--ap" not in line:
76
+ continue
77
+ if "update" not in line and "$" not in line:
78
+ continue
79
+ raise Deny("Refusing a command line over 16384 characters that holds --co, --ap and update or a "
80
+ "variable: the --confirm-delete rule does not run its pattern on a line that long."
81
+ + WHY_CONFIRM_DELETE)
82
+ if self.m.ere_match(CONFIRM_RE, joined, need=("--co", "--ap")):
83
+ raise Deny("Refusing 'update.py --apply --confirm-delete' from an agent session." + WHY_CONFIRM_DELETE)
84
+
85
+
86
+ def dialog_consent(self, cmd):
87
+ """(the dialog is on for this call, the command is the next live pick
88
+ of this session) — R36.11's pass-through. state.py spends the pick
89
+ after its own argv check (R36.10); the guard only lets it reach it."""
90
+ if not self.root or not exists(self.root + "/.ai/policies/dialog.json"):
91
+ return False, False
92
+ from datetime import datetime, timezone # pylint: disable=import-outside-toplevel
93
+ from guard import dialog # pylint: disable=import-outside-toplevel
94
+ root = os.fsdecode(b_bytes(self.root))
95
+ values = self.p.values
96
+ raw = values[0] if len(values) == 1 and isinstance(values[0], dict) else None
97
+ hook_file = os.path.join(os.fsdecode(b_bytes(self.ctx.hook_dir)), "ai-guard.py")
98
+ try:
99
+ if raw is None or not dialog.effective_switch(root, dialog.payload_runtime(raw, hook_file), raw):
100
+ return False, False
101
+ pick = dialog.next_live_pick(root, raw.get("session_id"), datetime.now(timezone.utc))
102
+ except Exception: # pylint: disable=broad-exception-caught
103
+ return True, False
104
+ command = b_bytes(cmd).decode("utf-8", "replace").strip()
105
+ return True, bool(pick and pick.get("label") == command)
@@ -0,0 +1,162 @@
1
+ """Interpreter one-liners and in-place edits: the patterns that scan the text of python -c, perl -e and
2
+ the like for a secret or a control-file write, the text-tool words that merely name them, and the two
3
+ rules inplace_edit and interpreter_text.
4
+
5
+ R16: every name here is core (no file, clock or subprocess access).
6
+ Moved from paths.py in WP5 (small-oop-modules), unchanged.
7
+ """
8
+ import re
9
+ from guard.common import Deny
10
+ from guard.paths_base import WHY_APPROVE, WHY_SENSITIVE
11
+ from guard.paths_shell import shell_segments
12
+
13
+
14
+ # An interpreter one-liner is not parsed, its text is scanned: one that names a
15
+ # secret, or one that names a control file and looks like a write, is refused (H3).
16
+ INTERP_RE = ("(^|[|;&[:space:]])(python3?|perl|ruby|node|php)([[:space:]]+-[^[:space:]]+)*[[:space:]]+-(c|e)([[:space:]]|$)|"
17
+ "(^|[|;&[:space:]])php([[:space:]]+-[^[:space:]]+)*[[:space:]]+-r([[:space:]]|$)")
18
+ CONTROL_TEXT_RE = "\\.ai/state/|\\.ai/reports/"
19
+ # perl or ruby with -i, behind any blank, separator, ( ` { or quote, so every wrapper and
20
+ # position is caught; Guard.inplace_edit carves out the segments of a text tool (grep, echo,
21
+ # git log ...) that merely name it. An -e/-E/-ne code argument may be one quoted word.
22
+ INPLACE_RE = ("(^|[|;&(`{'\"[:space:]])"
23
+ "(perl|ruby)([[:space:]]+-([CEIMmr][[:space:]]*[^[:space:]]+|[a-zA-Z]*[eE][[:space:]]+('[^']*'|\"[^\"]*\")|"
24
+ "[^[:space:]]+))*[[:space:]]+-[acdfghlnpsStTuUvwWXy]*i")
25
+ _TEXT_TOOLS = {"grep", "egrep", "fgrep", "rg", "ag", "ack", "echo", "printf"}
26
+ _GIT_TEXT = {"log", "grep", "commit", "show", "diff", "tag", "notes"}
27
+ _STD_WRITE = re.compile(r"(writeSync|writevSync)\([ \t]*(1|2|process\.std(out|err)\.fd)[ \t]*,|"
28
+ r"v?fprintf\([ \t]*STD(OUT|ERR)[ \t]*,")
29
+
30
+ _SHELL_SINKS = {"sh", "bash", "zsh", "dash", "ksh", "ssh", "source", ".", "eval"}
31
+ _XARGS_ARG = {"-I", "-n", "-P", "-L", "-s", "-E", "-d", "-a"}
32
+ _GIT_RUNS = ("GIT_", "PAGER", "EDITOR", "VISUAL", "SSH_COMMAND")
33
+ _GIT_CFG = re.compile(r"[A-Za-z0-9_.-]+=[A-Za-z0-9_./:-]*$")
34
+
35
+
36
+ def _tool_word(w):
37
+ """The bare word, /bin/<w> or /usr/bin/<w>; any other path is a program of its own."""
38
+ if w.startswith("/bin/"):
39
+ return w[5:]
40
+ if w.startswith("/usr/bin/"):
41
+ return w[9:]
42
+ return w
43
+
44
+
45
+ def shell_sink(seg):
46
+ """A segment that runs its input or its arguments as shell text."""
47
+ words = seg.split()
48
+ if not words:
49
+ return False
50
+ w = _tool_word(words[0])
51
+ if w in _SHELL_SINKS:
52
+ return True
53
+ return w == "xargs" and any(_tool_word(x) in _SHELL_SINKS for x in words[1:])
54
+
55
+
56
+ def _plain_text_tool(words, i, git_runs):
57
+ if i >= len(words):
58
+ return False
59
+ w = _tool_word(words[i])
60
+ if w in _TEXT_TOOLS:
61
+ return True
62
+ if w != "git" or git_runs:
63
+ return False
64
+ if any(x.startswith(("-O", "--open-files-in-pager", "--config-env")) for x in words[i + 1:]):
65
+ return False
66
+ k = i + 1
67
+ while k < len(words):
68
+ x = words[k]
69
+ if x in ("-C", "-c"):
70
+ if x == "-c" and (k + 1 >= len(words) or not _GIT_CFG.match(words[k + 1])):
71
+ return False
72
+ k += 2
73
+ elif x.startswith("-"):
74
+ k += 1
75
+ else:
76
+ return x in _GIT_TEXT
77
+ return False
78
+
79
+
80
+ def text_tool_segment(seg, git_runs=False):
81
+ """A segment whose command word is a text tool: a program named in it is an argument.
82
+ xargs and find -exec are looked through; git_runs is set when the line may hand git a
83
+ command to run (a stripped GIT_PAGER=... assignment)."""
84
+ words = seg.split()
85
+ if not words:
86
+ return False
87
+ w = _tool_word(words[0])
88
+ if w == "xargs":
89
+ i = 1
90
+ while i < len(words) and words[i].startswith("-"):
91
+ i += 2 if words[i] in _XARGS_ARG else 1
92
+ return _plain_text_tool(words, i, git_runs)
93
+ if w == "find":
94
+ found = False
95
+ for k, x in enumerate(words):
96
+ if x in ("-exec", "-execdir", "-ok", "-okdir"):
97
+ if not _plain_text_tool(words, k + 1, git_runs):
98
+ return False
99
+ found = True
100
+ return found
101
+ return _plain_text_tool(words, 0, git_runs)
102
+
103
+
104
+ EVAL_WORD_RE = "(^|[|;&[:space:]])eval([[:space:]]|$)"
105
+ SECRET_TEXT_RE = "\\.env|secrets?/|credentials?|id_rsa|\\.pem|\\.aws"
106
+ WRITE_CALL_RE = (
107
+ "(^|[^A-Za-z0-9_])(write|writelines|write_text|write_bytes|write_file|writeFile|writeFileSync|appendFile|"
108
+ "appendFileSync|createWriteStream|binwrite|syswrite|fwrite|fputs|fprintf|vfprintf|writeSync|writevSync|file_put_contents|dump|unlink|unlinkSync|"
109
+ "remove|removedirs|rename|renames|renameSync|rmdir|rmdirSync|rm|rmSync|rmtree|mkdir|mkdirSync|makedirs|"
110
+ "mkdtemp|truncate|truncateSync|ftruncate|symlink|symlinkSync|symlink_to|hardlink_to|link|linkSync|link_to|"
111
+ "touch|chmod|chown|utime|utimes|copyFile|copyFileSync|cp|cpSync|move|delete|system|exec|execSync|execFile|"
112
+ "execFileSync|spawn|spawnSync|popen|passthru|shell_exec|proc_open|fork|eval|copy_into|move_into)"
113
+ "([[:space:]]*\\(|[[:space:]]+['\"$])|(^|[^A-Za-z0-9_.])copy[[:space:]]*\\(")
114
+ WRITE_WORD_RE = ("subprocess|shutil|child_process|FileUtils|Open3|O_(WRONLY|RDWR|CREAT|APPEND|TRUNC)|os\\.replace|"
115
+ "inplace|`|\\$\\(|%x[^A-Za-z0-9_[:space:]]|(^|[^A-Za-z0-9_])qx[^A-Za-z0-9_[:space:]]")
116
+ FIRST_ARG = "([^,()'\"]|'[^']*'|\"[^\"]*\"|\\(([^()]|\\([^()]*\\))*\\))*"
117
+ WRITE_PATHREPL_RE = "\\.replace[[:space:]]*\\(" + FIRST_ARG + "\\)"
118
+ WRITE_PATHCOPY_RE = "\\.copy[[:space:]]*\\([[:space:]]*[^)[:space:]]"
119
+ WRITE_MODE_RE = ("((open[A-Za-z]*|new([[:space:]]+[A-Za-z_][A-Za-z0-9_:]*)?|FileIO)[[:space:]]*(\\(|[[:space:]])"
120
+ + FIRST_ARG + ",|mode[[:space:]]*=|open[A-Za-z]*[[:space:]]*\\()[[:space:]]*\\\\?['\"]"
121
+ "([rbtU]*[wax+][rbtU+]*\\\\?['\"]|>|\\+|-?\\|[^[:space:]|])")
122
+ WRITE_REDIR_RE = (">[>|]?[[:space:]]*['\"]?[^[:space:]'\"|;&<>]*\\.ai/(state|reports)/|"
123
+ "(^|[^A-Za-z0-9_.-])tee[[:space:]][^|;&]*\\.ai/(state|reports)/")
124
+
125
+
126
+ def inplace_edit(self, cmd):
127
+ """perl or ruby -i as a command: INPLACE_RE on the line less the segments of a text tool."""
128
+ m = self.m
129
+ if not m.ere_match(INPLACE_RE, cmd):
130
+ return False
131
+ if "<<" in cmd: # a heredoc body is read by whoever reads it
132
+ return True
133
+ segs = shell_segments(cmd).split("\n")
134
+ if "|" in cmd and any(shell_sink(sg) for sg in segs):
135
+ return True # a text tool's output may run as shell text
136
+ git_runs = any(t in cmd for t in _GIT_RUNS)
137
+ if git_runs and any(_tool_word(sg.split(None, 1)[0]) == "git" for sg in segs if sg.strip()):
138
+ return True # the assignment that runs a command is not in the segment
139
+ keep = [sg for sg in segs if not text_tool_segment(sg, git_runs)]
140
+ return m.ere_match(INPLACE_RE, "\n".join(keep))
141
+
142
+
143
+ def interpreter_text(self, cmd):
144
+ """The text of an interpreter one-liner or an eval, scanned whole (H3)."""
145
+ m = self.m
146
+ is_eval = m.ere_match(EVAL_WORD_RE, cmd)
147
+ is_interp = m.ere_match(INTERP_RE, cmd)
148
+ if (is_eval or is_interp) and m.ere_match(SECRET_TEXT_RE, cmd):
149
+ raise Deny("Refusing an interpreter one-liner that references a sensitive path." + WHY_SENSITIVE)
150
+ if m.ere_match(CONTROL_TEXT_RE, cmd):
151
+ if is_eval or self.inplace_edit(cmd):
152
+ raise Deny("Refusing an interpreter one-liner that writes the task's own control files." + WHY_APPROVE)
153
+ if is_interp:
154
+ scan = _STD_WRITE.sub(" ", cmd.replace("stdout.write", " ").replace("stderr.write", " "))
155
+ pscan = cmd.replace("copy.copy(", " ")
156
+ if (m.ere_match(WRITE_CALL_RE, scan) or m.ere_match(WRITE_WORD_RE, cmd)
157
+ or (m.ere_match("Path", cmd)
158
+ and (m.ere_match(WRITE_PATHREPL_RE, cmd) or m.ere_match(WRITE_PATHCOPY_RE, pscan)))
159
+ or (m.ere_match("open|new|FileIO", cmd) and m.ere_match(WRITE_MODE_RE, cmd))
160
+ or m.ere_match(WRITE_REDIR_RE, cmd)):
161
+ raise Deny("Refusing an interpreter one-liner that writes the task's own control files."
162
+ + WHY_APPROVE)
@@ -0,0 +1,393 @@
1
+ """The movers pass: cp, mv, rm, ln, sed -i, find -delete and their kin, resolved against the covered
2
+ roots, with MoverState, the per-call counters of that pass.
3
+
4
+ R16: shell (they read the file system, the clock or a subprocess): cover_init, realpath_m_arg, roots_under, covers_work, covers_protected, movers, mover_resolve, mover_check; every other name here is core.
5
+ Moved from paths.py in WP5 (small-oop-modules), unchanged.
6
+ """
7
+ import re
8
+ from guard.common import (Deny, chomp_all, env_b, exists, herestring_lines, is_dir, read_lines,
9
+ real_path, strip1)
10
+ from guard.paths_fs import LoopError, find_print, realpath_m
11
+ from guard.paths_base import SEGMENT, WHY_PROTECTED, WHY_TASK
12
+
13
+
14
+ MOVERS = SEGMENT + "(cp|mv|install|rsync|rm|rmdir|unlink)([[:space:]]|$)"
15
+ MOVER_VERBS = ("cp", "mv", "install", "rsync", "rm", "rmdir", "unlink")
16
+ REMOVE_VERBS = ("rm", "rmdir", "unlink")
17
+ MOVER_CHUNK = 32768
18
+ GLOBBY = set("][*?$`{}()\\~@!+")
19
+
20
+ _SED1 = "[0-9]*[<>]{1,2}&[0-9]+"
21
+ _SED2 = "(&|[0-9]*)[<>]{1,2}[[:space:]]*[^[:space:]|;&<>]*"
22
+
23
+ _FIND_PRUNE_ROOTS = {".git", "vendor", "node_modules", "projects"}
24
+ _FIND_PRUNE_SCAN = {".git", "vendor", "node_modules", "bower_components", "third_party", "Pods",
25
+ "site-packages", ".venv", ".pnpm", ".yarn"}
26
+
27
+
28
+ # -------------------------------------------------------------- covers
29
+
30
+ def cover_init(self):
31
+ if self.cover_done:
32
+ return
33
+ self.cover_done = True
34
+ parts = []
35
+ for p in read_lines(self.prot_p + "\n" + self.task_p + "\n"):
36
+ if not p:
37
+ continue
38
+ p = p[5:] if p.startswith("(^|/)") else p
39
+ p = p[1:] if p.startswith("^") else p
40
+ parts.append(p + "\n")
41
+ self.cover_joined = chomp_all("".join(parts)).replace("\n", "|")
42
+ home = self.home
43
+ roots = []
44
+ for r in (self.root + "/.ai", self.root + "/.claude", self.root + "/.codex",
45
+ env_b("CLAUDE_CONFIG_DIR") or home + "/.claude", env_b("CODEX_HOME") or home + "/.codex"):
46
+ got = self.realpath_m_arg(r)
47
+ if got is not None:
48
+ roots.append(got)
49
+ self.cover_roots = chomp_all("".join(r + "\n" for r in self.sort_u(roots)))
50
+
51
+
52
+ def realpath_m_arg(self, p):
53
+ """One argument of `realpath -m`, as the line it prints, or None."""
54
+ if p == "":
55
+ return None
56
+ full = p if p.startswith("/") else self.ctx.pwd.rstrip("/") + "/" + p
57
+ try:
58
+ return realpath_m(full)
59
+ except LoopError:
60
+ return None
61
+
62
+
63
+ def sort_u(self, items):
64
+ key = self.loc.sort_key
65
+ out, seen = [], set()
66
+ for item in sorted(items, key=key):
67
+ k = key(item)
68
+ if k not in seen:
69
+ seen.add(k)
70
+ out.append(item)
71
+ return out
72
+
73
+
74
+ def roots_under(self, c):
75
+ for r in herestring_lines(self.cover_roots):
76
+ if r and (c == "/" or r == c or r.startswith(c + "/")) and exists(r):
77
+ yield r
78
+
79
+
80
+ def covers_work(self, real, lexical):
81
+ c = strip1(real, "/") or "/"
82
+ for _ in self.roots_under(c):
83
+ self.work_cap("scan")
84
+ return True
85
+ if is_dir(c):
86
+ self.work_cap("scan")
87
+ return True
88
+ return bool(lexical)
89
+
90
+
91
+ def covers_protected(self, real):
92
+ c = strip1(real, "/") or "/"
93
+ for line in (c, c + "/"):
94
+ if not self.m.ere_match(self.loose_joined, line):
95
+ continue
96
+ v = self.classify(line)
97
+ if v and (v[0] == "protected" or (v[0] == "task" and self.task_in_flight(remember=False))):
98
+ return strip1(line, "/")
99
+ roots = list(self.roots_under(c))
100
+ scan = [c] if is_dir(c) else []
101
+ if not roots and not scan:
102
+ return None
103
+
104
+ def stream():
105
+ for r in roots:
106
+ yield from find_print(r, 3, _FIND_PRUNE_ROOTS)
107
+ count = 0
108
+ for s in scan:
109
+ for path in find_print(s, 6, _FIND_PRUNE_SCAN):
110
+ if count >= 20000:
111
+ return
112
+ count += 1
113
+ yield path
114
+
115
+ pats = self.cover_joined.split("\n")
116
+ valid = all(self.m.ere.valid(pt) for pt in pats)
117
+ picked = 0
118
+ if not valid:
119
+ return None
120
+ for path in stream():
121
+ for line in (path, path + "/"):
122
+ if not any(self.m.search_c(pt, line) for pt in pats):
123
+ continue
124
+ picked += 1
125
+ if picked > 50:
126
+ return None
127
+ v = self.classify(line)
128
+ if v and (v[0] == "protected" or (v[0] == "task" and self.task_in_flight(remember=False))):
129
+ return strip1(line, "/")
130
+ return None
131
+
132
+
133
+ def loose_hits(self, lines):
134
+ """1-based numbers of the lines that match the loose patterns, bytewise."""
135
+ pats = self.loose_joined.split("\n")
136
+ if not all(self.m.ere.valid(pt) for pt in pats):
137
+ return []
138
+ return [n for n, line in enumerate(lines, 1) if any(self.m.search_c(pt, line) for pt in pats)]
139
+
140
+
141
+ # -------------------------------------------------------------- the movers pass
142
+
143
+ def movers(self, segs):
144
+ m = self.m
145
+ if not (segs and m.ere_match(MOVERS, segs)):
146
+ return
147
+ self.cover_init()
148
+ sed1 = m.ere.compile_ere(self.loc_pat(_SED1), self.loc_mode(), capture=True).rx
149
+ sed2 = m.ere.compile_ere(self.loc_pat(_SED2), self.loc_mode(), capture=True).rx
150
+ kept = []
151
+ for line in segs.split("\n"):
152
+ t = self.loc_text(line)
153
+ t = sed1.sub("", t) if sed1 else t
154
+ t = sed2.sub("", t) if sed2 else t
155
+ line = self.from_loc(t)
156
+ if m.search_c(MOVERS, line):
157
+ kept.append(line)
158
+ segments = chomp_all("".join(k + "\n" for k in kept))
159
+ st = MoverState()
160
+ cwd_slash = strip1(self.cwd, "/") + "/"
161
+ length = self.loc.length
162
+ mover_base = max(length(self.cwd), length(self.home)) + 2
163
+ pending = ""
164
+ for seg in herestring_lines(segments):
165
+ self.work_clock()
166
+ if not st.batch + length(seg) < MOVER_CHUNK:
167
+ self.mover_check(st)
168
+ st.batch += length(seg) + 1
169
+ operands = [w for w in re.split("[ \t]+", seg) if w]
170
+ if len(operands) <= 1:
171
+ continue
172
+ verb = operands[0]
173
+ if verb not in MOVER_VERBS:
174
+ continue
175
+ dest, sources, opts_done, want_dest = "", [], False, False
176
+ stop = False
177
+ for w in operands[1:]:
178
+ self.work_clock()
179
+ if length(w) > 4096:
180
+ if any(c in GLOBBY for c in w):
181
+ pending = ("Refusing a shell %s with a %d-character operand that holds a glob or "
182
+ "expansion character: the guard cannot check what it expands to."
183
+ % (verb, length(w)))
184
+ stop = True
185
+ break
186
+ if not opts_done and not want_dest:
187
+ if w.startswith("--target-directory="):
188
+ dest = "LONG-OPERAND"
189
+ continue
190
+ if w.startswith("-"):
191
+ continue
192
+ w = "LONG-OPERAND"
193
+ if want_dest:
194
+ want_dest = False
195
+ dest = w
196
+ continue
197
+ if not opts_done:
198
+ if w == "--":
199
+ opts_done = True
200
+ continue
201
+ if w in ("-t", "--target-directory"):
202
+ if verb not in REMOVE_VERBS:
203
+ want_dest = True
204
+ continue
205
+ if w.startswith("--target-directory="):
206
+ dest = w.split("=", 1)[1]
207
+ continue
208
+ if w.startswith("-"):
209
+ continue
210
+ w = w.lstrip("\"'").rstrip("\"'")
211
+ if w:
212
+ sources.append(w)
213
+ if stop:
214
+ break
215
+ dl = 0
216
+ if verb not in REMOVE_VERBS:
217
+ dl = length(dest)
218
+ if not dest and sources:
219
+ dl = length(sources[-1])
220
+ if st.seg_first and not (st.batch + (2 * len(sources) + 1) * (mover_base + dl) + 2 * length(seg)
221
+ < MOVER_CHUNK):
222
+ self.mover_check(st)
223
+ first = len(st.cands)
224
+ if verb in REMOVE_VERBS:
225
+ for src in sources:
226
+ st.cands.append(src)
227
+ st.covers.append("yes")
228
+ else:
229
+ if not dest:
230
+ if len(sources) <= 1:
231
+ continue
232
+ dest = sources.pop()
233
+ dest = dest.lstrip("\"'").rstrip("\"'")
234
+ if not dest:
235
+ continue
236
+ into = ""
237
+ for src in sources:
238
+ if verb in ("rsync", "cp") and (src.endswith("/") or src.endswith("/.")):
239
+ into = "yes"
240
+ st.cands.append(dest)
241
+ st.covers.append(into)
242
+ for src in sources:
243
+ src = strip1(strip1(src, "/"), "/.")
244
+ st.cands.append(strip1(dest, "/") + "/" + src.rsplit("/", 1)[-1])
245
+ st.covers.append("yes")
246
+ if verb == "mv":
247
+ st.cands.append(src)
248
+ st.covers.append("yes")
249
+ if len(st.cands) <= first:
250
+ continue
251
+ for k in range(first, len(st.cands)):
252
+ self.work_clock()
253
+ cand = st.cands[k]
254
+ if cand in ("~", "$HOME", "${HOME}"):
255
+ cand = self.home
256
+ elif cand.startswith("~/"):
257
+ cand = self.home + "/" + cand[2:]
258
+ elif cand.startswith("$HOME/"):
259
+ cand = self.home + "/" + cand[6:]
260
+ elif cand.startswith("${HOME}/"):
261
+ cand = self.home + "/" + cand[8:]
262
+ if not cand.startswith("/"):
263
+ cand = cwd_slash + cand
264
+ st.abss.append(cand)
265
+ st.batch += length(cand) + 1
266
+ st.seg_first.append(first)
267
+ st.seg_count.append(len(st.cands) - first)
268
+ st.seg_verb.append(verb)
269
+ self.mover_check(st)
270
+ if pending:
271
+ raise Deny(pending)
272
+
273
+
274
+ def loc_mode(self):
275
+ return "u" if self.loc.utf8 else "b"
276
+
277
+
278
+ def loc_pat(self, p):
279
+ return self.loc.u(p) if self.loc.utf8 else p
280
+
281
+
282
+ def loc_text(self, s):
283
+ return self.loc.u(s) if self.loc.utf8 else s
284
+
285
+
286
+ def from_loc(self, t):
287
+ return t.encode("utf-8", "surrogateescape").decode("latin-1") if self.loc.utf8 else t
288
+
289
+
290
+ def mover_resolve(self, st, first, end, one_at_a_time=False):
291
+ length = self.loc.length
292
+ size, cuts = 0, []
293
+ for k in range(first, end):
294
+ self.work_clock()
295
+ if not size < MOVER_CHUNK:
296
+ cuts.append(k)
297
+ size = 0
298
+ size += length(st.abss[k]) + 1
299
+ cuts.append(end)
300
+ at, got = first, []
301
+ for k in cuts:
302
+ self.work_clock()
303
+ args = st.abss[at:k]
304
+ had = len(got)
305
+ for a in args:
306
+ r = self.realpath_m_arg(a)
307
+ if r is not None:
308
+ got.append(r)
309
+ if len(got) - had != len(args):
310
+ if not one_at_a_time:
311
+ return False
312
+ got = []
313
+ for n in range(first, end):
314
+ self.work_cap("classify")
315
+ r = self.realpath_m_arg(st.abss[n])
316
+ got.append(r if r else st.abss[n])
317
+ break
318
+ at = k
319
+ for n in range(first, end):
320
+ st.reals[n] = got[n - first]
321
+ at = first
322
+ for k in cuts:
323
+ self.work_clock()
324
+ lines = []
325
+ for n in range(at, k):
326
+ norm = strip1(st.reals[n], "/") or "/"
327
+ lines += [st.abss[n] + " " + st.reals[n], norm, norm + "/"]
328
+ for r in self.loose_hits(lines):
329
+ st.hits.add(3 * at + r)
330
+ at = k
331
+ return True
332
+
333
+
334
+ def mover_check(self, st):
335
+ per_seg = False
336
+ st.reals, st.hits = {}, set()
337
+ if st.abss and not self.mover_resolve(st, 0, len(st.abss)):
338
+ per_seg = True
339
+ for s, first in enumerate(st.seg_first):
340
+ last = first + st.seg_count[s]
341
+ verb = st.seg_verb[s]
342
+ if per_seg:
343
+ self.mover_resolve(st, first, last, one_at_a_time=True)
344
+ self.work_clock()
345
+ for i in range(first, last):
346
+ self.work_clock()
347
+ a, real, cand = st.abss[i], st.reals[i], st.cands[i]
348
+ how = "copy or move into"
349
+ if verb in REMOVE_VERBS:
350
+ how = "removal of"
351
+ elif verb == "mv" and i > first and a != strip1(st.abss[first], "/") + "/" + a.rsplit("/", 1)[-1]:
352
+ how = "move of"
353
+ verdict = None
354
+ lexical = (3 * i + 2) in st.hits or (3 * i + 3) in st.hits
355
+ if (3 * i + 1) in st.hits or (st.covers[i] == "yes" and lexical):
356
+ self.work_cap("classify")
357
+ if (3 * i + 1) in st.hits:
358
+ verdict = (self.classify(a) or self.classify(real)
359
+ or self.classify(real_path(a, self.cwd)))
360
+ kind = verdict[0] if verdict else ""
361
+ if kind == "protected":
362
+ raise Deny("Refusing a shell %s a RiskRails control file: %s%s" % (how, cand, WHY_PROTECTED))
363
+ if kind == "task" and self.task_in_flight():
364
+ raise Deny("Refusing a shell %s runtime configuration while task %s is in flight: %s%s"
365
+ % (how, self.task_id, cand, WHY_TASK))
366
+ if st.covers[i] != "yes":
367
+ continue
368
+ if not self.covers_work(real, lexical):
369
+ continue
370
+ found = self.covers_protected(real)
371
+ if found is None:
372
+ continue
373
+ verdict = self.classify(found) or self.classify(found + "/")
374
+ held = "" if found == strip1(real, "/") else " (holds %s)" % found
375
+ if verdict and verdict[0] == "protected":
376
+ raise Deny("Refusing a shell %s a directory that holds a RiskRails control file: %s%s%s"
377
+ % (how, cand, held, WHY_PROTECTED))
378
+ raise Deny("Refusing a shell %s a directory that holds runtime configuration while task %s is "
379
+ "in flight: %s%s%s" % (how, self.task_id, cand, held, WHY_TASK))
380
+ st.reset()
381
+
382
+
383
+ class MoverState: # pylint: disable=too-many-instance-attributes,too-few-public-methods
384
+ """One batch of the movers pass: candidates and the segments that own them."""
385
+
386
+ def __init__(self):
387
+ self.reset()
388
+
389
+ def reset(self):
390
+ self.cands, self.covers, self.abss = [], [], []
391
+ self.seg_first, self.seg_count, self.seg_verb = [], [], []
392
+ self.reals, self.hits = {}, set()
393
+ self.batch = 0