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
@@ -14,627 +14,20 @@ the awk segmenter (shell_segments), each `grep` (Matcher.grep_q, loose_hits),
14
14
  the redirection `sed`. What ran inside `$( )` and so could not change the
15
15
  guard's own state still cannot: covers_protected asks whether a task is in
16
16
  flight without remembering the answer, as its subshell forgot it.
17
+ The rule groups live in the sibling modules paths_base, paths_fs, paths_shell, paths_human, paths_interp,
18
+ paths_classify, paths_movers and paths_bash, imported as guard.<name>; this file binds their methods.
17
19
  """
18
- import os
19
- import re
20
- import stat
21
-
22
- from guard.common import (Allow, Deny, abs_path, b_bytes, bytes_b, chomp_all, ere_escape,
23
- env_b, exists, find_ai_root, herestring_lines, is_dir, is_file,
24
- is_jq_string, jq_iter, jq_tostring, load_json_file, read_lines,
25
- real_path, realpath_strict, strip1, text_to_b)
26
-
27
- T = text_to_b
28
-
29
- Q = "['\"]?"
30
- END = "([[:space:]]|['\"]|$)"
31
- VAR = "\\$\\{?[A-Za-z_][A-Za-z0-9_]*\\}?"
32
-
33
- WHY_CONFIRM_DELETE = T(
34
- "\n\n--confirm-delete is a human confirming an irreversible deletion, and the name\n"
35
- "typed is the audit trail. A human runs the same command in a terminal\n"
36
- "outside Claude Code or Codex (not through the ! prefix: that shell is the\n"
37
- "agent's). An unattended run exports AI_UNATTENDED=1 in the launcher's environment, and the\n"
38
- "record then says via: unattended. See skills/project-update/SKILL.md.")
39
- WHY_SENSITIVE = T(
40
- "\n\nProduction secrets and data dumps must not enter the model context.\n"
41
- "If this path is genuinely safe (a .dist/.example file, a fixture), add a regex to\n"
42
- "\"allow_patterns\" in .ai/policies/path-guard.json. See .ai/policies/security.md.")
43
- WHY_PROTECTED = T(
44
- "\n\nThese files are the guard configuration and the task state. State is written by\n"
45
- "skills/ai-task/state.py, and during a schema migration by\n"
46
- "skills/project-update/update.py; policy files are edited by a human outside an\n"
47
- "agent run, so a change to them is reviewable. See .ai/policies/safety.md.")
48
- WHY_TASK = T(
49
- "\n\nWhile a task is in flight the runtime's own configuration is frozen: settings,\n"
50
- "agent and skill definitions, commands, hooks, and the pipeline's policies and\n"
51
- "workflows. A run that edits the rules it is being judged by is how scope and\n"
52
- "review quietly get weaker. Finish or archive the task\n"
53
- "(skills/ai-task/state.py close), then change this through /project-update or by\n"
54
- "hand outside a run. See .ai/policies/safety.md.")
55
- WHY_APPROVE = T(
56
- "\n\nApproval happens outside the agent. A human runs, in their own terminal:\n"
57
- " python3 <plugin>/skills/ai-task/state.py --root . approve --by \"<name>\"\n"
58
- "or sets [Answer]: A on the gate question in .ai/reports/<task-id>/task.md\n"
59
- "and tells the session to run state.py questions --sync. An unattended run\n"
60
- "exports AI_UNATTENDED=1 in the launcher's environment, and the journal then\n"
61
- "records the approval as unattended for ever. See .ai/policies/safety.md.")
62
- WHY_WORK_CAP = T(
63
- "This command names more paths than the guard can check within its timeout; split it into "
64
- "smaller commands, or pass the list through a file and xargs.")
65
-
66
- TOKEN_END = "([[:space:]\"';&|)<>]|$)"
67
-
68
- # The --confirm-delete rule, and --confirm-cleanup (the same human gate; any argparse prefix).
69
- SEP = "(([^|;&]|[<>]&|&>)*[[:space:]])?" + Q
70
- CONFIRM_DEL = "d(e(l(e(t(e)?)?)?)?)?"
71
- CONFIRM_CLEAN = "c(l(e(a(n(u(p)?)?)?)?)?)?"
72
- CONFIRM = ("--co(n(f(i(r(m(-(" + CONFIRM_DEL + "|" + CONFIRM_CLEAN + ")?)?)?)?)?)?)?"
73
- "(=|[[:space:]]|['\"]|$)")
74
- APPLY = "--ap(p(l(y)?)?)?" + END
75
- HEAD = "(^|[[:space:]'\"/|;&(`])(update\\.py|-[A-Za-z]*m[[:space:]]*" + Q + "update|" + VAR + ")" + Q + "[[:space:]]"
76
- CONFIRM_RE = HEAD + SEP + CONFIRM + SEP + APPLY + "|" + HEAD + SEP + APPLY + SEP + CONFIRM
77
-
78
- # The gate rules.
79
- ARGS = "([[:space:]]+--r[a-z]*(=|[[:space:]]+)(\"[^\"]*\"|'[^']*'|[^[:space:]]+))*"
80
- VERB = "([[:space:]]+--)?[[:space:]]+" + Q + "approve" + END
81
- BOUND = "(^|[|;&([:space:]`])"
82
- APPROVE_RE = (BOUND + "(python3?[[:space:]]+)?" + Q + "[^[:space:]'\"]*state\\.py" + Q + ARGS + VERB + "|"
83
- + BOUND + "(python3?[[:space:]]+)?" + Q + VAR + Q + ARGS + VERB)
84
- # R36.11: the other human-only state.py commands — `profile set` (dialog on only)
85
- # and the --by forms of verify, review and reject (dialog on or off) — run from an
86
- # agent session only as the next live pick, like approve. `start --mode light --by`
87
- # is denied with the dialog off only: `light` is the one --mode value that can
88
- # never raise a mode; with the dialog on it reaches state.py, which opens the
89
- # lowering decision (review H1, T-2026-10-28-010); any other `start --mode X --by`
90
- # reaches state.py too (T-2026-10-30-0758 Q1, Q2).
91
- PROFILE_SET_VERB = "([[:space:]]+--)?[[:space:]]+" + Q + "profile" + Q + "[[:space:]]+" + Q + "set" + END
92
- PROFILE_SET_RE = (BOUND + "(python3?[[:space:]]+)?" + Q + "[^[:space:]'\"]*state\\.py" + Q + ARGS + PROFILE_SET_VERB
93
- + "|" + BOUND + "(python3?[[:space:]]+)?" + Q + VAR + Q + ARGS + PROFILE_SET_VERB)
94
- BY_TOK = Q + "--by?" + Q + "(=|[[:space:]]|$)"
95
- MODE_LIGHT = Q + "--m(o(d(e)?)?)?" + Q + "(=|[[:space:]]+)" + Q + "light" + END
96
- SKIP = "([^|;&`]*[[:space:]])?"
97
- HUMAN_BY_VERB = ("([[:space:]]+--)?[[:space:]]+" + Q + "(verify|review|reject)" + END + SKIP + BY_TOK)
98
- START_LIGHT_VERB = ("([[:space:]]+--)?[[:space:]]+" + Q + "start" + END + SKIP + "("
99
- + MODE_LIGHT + SKIP + BY_TOK + "|" + BY_TOK + SKIP + MODE_LIGHT + ")")
100
-
101
-
102
- def state_verb_re(verb):
103
- """The state.py command whose verb part is `verb`, as an ERE: by path or by a variable."""
104
- return (BOUND + "(python3?[[:space:]]+)?" + Q + "[^[:space:]'\"]*state\\.py" + Q + ARGS
105
- + "(" + verb + ")|" + BOUND + "(python3?[[:space:]]+)?" + Q + VAR + Q + ARGS + "(" + verb + ")")
106
-
107
-
108
- HUMAN_BY_RE = state_verb_re(HUMAN_BY_VERB)
109
- START_LIGHT_RE = state_verb_re(START_LIGHT_VERB)
110
- WHY_HUMAN = T(
111
- "\n\nThis command is the human's, whether or not the dialog is on. A human runs it in their\n"
112
- "own terminal, outside the agent session; an unattended run exports AI_UNATTENDED=1 in the\n"
113
- "launcher's environment. See .ai/policies/safety.md.")
114
- WHY_PICK = T(
115
- "\n\nWith the dialog on, this command runs from an agent session only as the human's pick.\n"
116
- "The command that needs it opened a decision: 'state.py decision show' prints the dialog;\n"
117
- "send it and run the picked command verbatim, once. Otherwise the human runs it in their\n"
118
- "own terminal. See .ai/policies/safety.md.")
119
- # R36.11: running the dialog's recorder by hand would forge the human's answer.
120
- # dialog-gate.py in command position — the program itself, or the script a
121
- # python runs — after a separator, a quote, exec/env/command/nohup/time/sudo
122
- # and their words; naming it as an argument (grep, git diff, chmod) is not running it.
123
- DIALOG_GATE_RE = ("(^|[|;&(`{'\"]|\\$\\()[[:space:]]*((exec|env|command|nohup|time|sudo)([[:space:]]+[^[:space:]|;&]+)*"
124
- "[[:space:]]+)?(" + Q + "[^[:space:]'\"|;&]*python[0-9.]*" + Q
125
- + "([[:space:]]+-[^[:space:]]+)*[[:space:]]+)?" + Q + "[^[:space:]'\"|;&]*dialog-gate\\.py" + Q
126
- + "([[:space:]|;&)<>]|$)")
127
- VERBS_RE = ("(^|[^A-Za-z0-9_.-])(cp|mv|install|rsync|rm|rmdir|unlink|tee|truncate|shred|touch|ln|chmod|chown|"
128
- "dd|sed|perl|ruby)([^A-Za-z0-9_.-]|$)")
129
- SEGMENT = "^[[:space:]]*"
130
- MOVERS = SEGMENT + "(cp|mv|install|rsync|rm|rmdir|unlink)([[:space:]]|$)"
131
- MOVER_VERBS = ("cp", "mv", "install", "rsync", "rm", "rmdir", "unlink")
132
- REMOVE_VERBS = ("rm", "rmdir", "unlink")
133
- MOVER_CHUNK = 32768
134
- GLOBBY = set("][*?$`{}()\\~@!+")
135
-
136
- READERS = ("(^|[|;&[:space:]])(cat|bat|less|more|head|tail|strings|xxd|od|hexdump|base64|nl|tac|rev)"
137
- "([[:space:]]+-[^[:space:]]+)*[[:space:]]+")
138
- COPIERS = "(^|[|;&[:space:]])(cp|mv|rsync|scp|install|tar|zip|curl|wget)([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+"
139
- WRITERS = SEGMENT + "(tee|truncate|shred|touch|ln)([[:space:]]+-[^[:space:]]+)*[[:space:]]+"
140
- REMOVERS = SEGMENT + "(rm|rmdir|unlink)([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+"
141
- MODES = SEGMENT + "(chmod|chown)([[:space:]]+-[^[:space:]]+)*[[:space:]]+[^-][^[:space:]]*[[:space:]]+"
142
- DDWRITE = SEGMENT + "dd([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+"
143
- INPLACE = SEGMENT + "(sed|perl|ruby)([[:space:]]+-[^[:space:]]*i[^[:space:]]*)([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+"
144
- LN_RE = "(^|[|;&[:space:]])ln([[:space:]]+-[^[:space:]]+)*[[:space:]]"
145
- LN_SRC_RE = ".*(^|[|;&[:space:]])ln([[:space:]]+-[^[:space:]]+)*[[:space:]]+([^[:space:];&|]+).*"
146
- # Words that make a long command worth a full check; past LONG_CMD the guard
147
- # refuses rather than run its patterns over the text (R22, D3).
148
- LONG_CMD = 4096
149
- # Whole words only, so --cov, --config and approve_invoice do not trip it.
150
- LONG_TRIGGER_RE = ("(^|[^A-Za-z0-9_])approve([^A-Za-z0-9_]|$)|--by(=|[[:space:]]|$)|"
151
- "--con?f?i?r?m?-?(d?e?l?e?t?e?|c?l?e?a?n?u?p?)(=|[[:space:]]|$)|state\\.py|update\\.py")
152
- # Tools that write a file named on their command line without a redirect; the
153
- # movers do not parse them, so any protected path they name is refused (H1).
154
- FETCHERS = "(^|[|;&[:space:]])(scp|tar|zip|unzip|curl|wget)([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+"
155
- # An interpreter one-liner is not parsed, its text is scanned: one that names a
156
- # secret, or one that names a control file and looks like a write, is refused (H3).
157
- INTERP_RE = ("(^|[|;&[:space:]])(python3?|perl|ruby|node|php)([[:space:]]+-[^[:space:]]+)*[[:space:]]+-(c|e)([[:space:]]|$)|"
158
- "(^|[|;&[:space:]])php([[:space:]]+-[^[:space:]]+)*[[:space:]]+-r([[:space:]]|$)")
159
- CONTROL_TEXT_RE = "\\.ai/state/|\\.ai/reports/"
160
- # perl or ruby with -i, behind any blank, separator, ( ` { or quote, so every wrapper and
161
- # position is caught; Guard.inplace_edit carves out the segments of a text tool (grep, echo,
162
- # git log ...) that merely name it. An -e/-E/-ne code argument may be one quoted word.
163
- INPLACE_RE = ("(^|[|;&(`{'\"[:space:]])"
164
- "(perl|ruby)([[:space:]]+-([CEIMmr][[:space:]]*[^[:space:]]+|[a-zA-Z]*[eE][[:space:]]+('[^']*'|\"[^\"]*\")|"
165
- "[^[:space:]]+))*[[:space:]]+-[acdfghlnpsStTuUvwWXy]*i")
166
- _TEXT_TOOLS = {"grep", "egrep", "fgrep", "rg", "ag", "ack", "echo", "printf"}
167
- _GIT_TEXT = {"log", "grep", "commit", "show", "diff", "tag", "notes"}
168
- _STD_WRITE = re.compile(r"(writeSync|writevSync)\([ \t]*(1|2|process\.std(out|err)\.fd)[ \t]*,|"
169
- r"v?fprintf\([ \t]*STD(OUT|ERR)[ \t]*,")
170
-
171
-
172
- _SHELL_SINKS = {"sh", "bash", "zsh", "dash", "ksh", "ssh", "source", ".", "eval"}
173
- _XARGS_ARG = {"-I", "-n", "-P", "-L", "-s", "-E", "-d", "-a"}
174
- _GIT_RUNS = ("GIT_", "PAGER", "EDITOR", "VISUAL", "SSH_COMMAND")
175
- _GIT_CFG = re.compile(r"[A-Za-z0-9_.-]+=[A-Za-z0-9_./:-]*$")
176
-
177
-
178
- def _tool_word(w):
179
- """The bare word, /bin/<w> or /usr/bin/<w>; any other path is a program of its own."""
180
- if w.startswith("/bin/"):
181
- return w[5:]
182
- if w.startswith("/usr/bin/"):
183
- return w[9:]
184
- return w
185
-
186
-
187
- def shell_sink(seg):
188
- """A segment that runs its input or its arguments as shell text."""
189
- words = seg.split()
190
- if not words:
191
- return False
192
- w = _tool_word(words[0])
193
- if w in _SHELL_SINKS:
194
- return True
195
- return w == "xargs" and any(_tool_word(x) in _SHELL_SINKS for x in words[1:])
196
-
197
-
198
- def _plain_text_tool(words, i, git_runs):
199
- if i >= len(words):
200
- return False
201
- w = _tool_word(words[i])
202
- if w in _TEXT_TOOLS:
203
- return True
204
- if w != "git" or git_runs:
205
- return False
206
- if any(x.startswith(("-O", "--open-files-in-pager", "--config-env")) for x in words[i + 1:]):
207
- return False
208
- k = i + 1
209
- while k < len(words):
210
- x = words[k]
211
- if x in ("-C", "-c"):
212
- if x == "-c" and (k + 1 >= len(words) or not _GIT_CFG.match(words[k + 1])):
213
- return False
214
- k += 2
215
- elif x.startswith("-"):
216
- k += 1
217
- else:
218
- return x in _GIT_TEXT
219
- return False
220
-
221
-
222
- def text_tool_segment(seg, git_runs=False):
223
- """A segment whose command word is a text tool: a program named in it is an argument.
224
- xargs and find -exec are looked through; git_runs is set when the line may hand git a
225
- command to run (a stripped GIT_PAGER=... assignment)."""
226
- words = seg.split()
227
- if not words:
228
- return False
229
- w = _tool_word(words[0])
230
- if w == "xargs":
231
- i = 1
232
- while i < len(words) and words[i].startswith("-"):
233
- i += 2 if words[i] in _XARGS_ARG else 1
234
- return _plain_text_tool(words, i, git_runs)
235
- if w == "find":
236
- found = False
237
- for k, x in enumerate(words):
238
- if x in ("-exec", "-execdir", "-ok", "-okdir"):
239
- if not _plain_text_tool(words, k + 1, git_runs):
240
- return False
241
- found = True
242
- return found
243
- return _plain_text_tool(words, 0, git_runs)
244
-
245
-
246
- EVAL_WORD_RE = "(^|[|;&[:space:]])eval([[:space:]]|$)"
247
- SECRET_TEXT_RE = "\\.env|secrets?/|credentials?|id_rsa|\\.pem|\\.aws"
248
- WRITE_CALL_RE = (
249
- "(^|[^A-Za-z0-9_])(write|writelines|write_text|write_bytes|write_file|writeFile|writeFileSync|appendFile|"
250
- "appendFileSync|createWriteStream|binwrite|syswrite|fwrite|fputs|fprintf|vfprintf|writeSync|writevSync|file_put_contents|dump|unlink|unlinkSync|"
251
- "remove|removedirs|rename|renames|renameSync|rmdir|rmdirSync|rm|rmSync|rmtree|mkdir|mkdirSync|makedirs|"
252
- "mkdtemp|truncate|truncateSync|ftruncate|symlink|symlinkSync|symlink_to|hardlink_to|link|linkSync|link_to|"
253
- "touch|chmod|chown|utime|utimes|copyFile|copyFileSync|cp|cpSync|move|delete|system|exec|execSync|execFile|"
254
- "execFileSync|spawn|spawnSync|popen|passthru|shell_exec|proc_open|fork|eval|copy_into|move_into)"
255
- "([[:space:]]*\\(|[[:space:]]+['\"$])|(^|[^A-Za-z0-9_.])copy[[:space:]]*\\(")
256
- WRITE_WORD_RE = ("subprocess|shutil|child_process|FileUtils|Open3|O_(WRONLY|RDWR|CREAT|APPEND|TRUNC)|os\\.replace|"
257
- "inplace|`|\\$\\(|%x[^A-Za-z0-9_[:space:]]|(^|[^A-Za-z0-9_])qx[^A-Za-z0-9_[:space:]]")
258
- FIRST_ARG = "([^,()'\"]|'[^']*'|\"[^\"]*\"|\\(([^()]|\\([^()]*\\))*\\))*"
259
- WRITE_PATHREPL_RE = "\\.replace[[:space:]]*\\(" + FIRST_ARG + "\\)"
260
- WRITE_PATHCOPY_RE = "\\.copy[[:space:]]*\\([[:space:]]*[^)[:space:]]"
261
- WRITE_MODE_RE = ("((open[A-Za-z]*|new([[:space:]]+[A-Za-z_][A-Za-z0-9_:]*)?|FileIO)[[:space:]]*(\\(|[[:space:]])"
262
- + FIRST_ARG + ",|mode[[:space:]]*=|open[A-Za-z]*[[:space:]]*\\()[[:space:]]*\\\\?['\"]"
263
- "([rbtU]*[wax+][rbtU+]*\\\\?['\"]|>|\\+|-?\\|[^[:space:]|])")
264
- WRITE_REDIR_RE = (">[>|]?[[:space:]]*['\"]?[^[:space:]'\"|;&<>]*\\.ai/(state|reports)/|"
265
- "(^|[^A-Za-z0-9_.-])tee[[:space:]][^|;&]*\\.ai/(state|reports)/")
266
-
267
- _SED1 = "[0-9]*[<>]{1,2}&[0-9]+"
268
- _SED2 = "(&|[0-9]*)[<>]{1,2}[[:space:]]*[^[:space:]|;&<>]*"
269
- _KEYWORDS = {"if", "then", "else", "elif", "do", "while", "until", "!", "{", "}", "time", "exec",
270
- "command", "nohup", "builtin", "env", "sudo"}
271
- _FLAGGED = {"time", "exec", "command", "env", "sudo"}
272
- _ASSIGN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\+?=")
273
- _SPECIAL_OUT = re.compile(r"""['$"#<(\n`)&;|\\]""")
274
- _SPECIAL_D = re.compile(r'["$`\n\\]')
275
- _SPECIAL_A = re.compile(r"['\n\\]")
276
- _FIND_PRUNE_ROOTS = {".git", "vendor", "node_modules", "projects"}
277
- _FIND_PRUNE_SCAN = {".git", "vendor", "node_modules", "bower_components", "third_party", "Pods",
278
- "site-packages", ".venv", ".pnpm", ".yarn"}
279
-
280
-
281
- class LoopError(OSError):
282
- """A path that does not resolve: realpath prints an error, not a line."""
283
-
284
-
285
- def realpath_m(path):
286
- """`realpath -m` of one absolute path: every link that exists resolved,
287
- what does not exist appended as written, `..` after resolution; a symlink
288
- loop raises LoopError."""
289
- seen = {}
290
-
291
- def join(base, rest):
292
- if rest.startswith(b"/"):
293
- base = b"/"
294
- for name in rest.split(b"/"):
295
- if name in (b"", b"."):
296
- continue
297
- if name == b"..":
298
- base = os.path.dirname(base) if base != b"/" else base
299
- continue
300
- new = base.rstrip(b"/") + b"/" + name
301
- try:
302
- is_link = stat.S_ISLNK(os.lstat(new).st_mode)
303
- except OSError:
304
- is_link = False
305
- if not is_link:
306
- base = new
307
- continue
308
- if new in seen:
309
- if seen[new] is None:
310
- raise LoopError()
311
- base = seen[new]
312
- continue
313
- seen[new] = None
314
- try:
315
- target = os.readlink(new)
316
- except OSError as exc:
317
- raise LoopError() from exc
318
- base = join(base, target)
319
- seen[new] = base
320
- return base
321
-
322
- try:
323
- return bytes_b(join(b"/", b_bytes(path)))
324
- except RecursionError as exc:
325
- raise LoopError() from exc
326
-
327
-
328
- def find_print(start, maxdepth, prune):
329
- """`find START -maxdepth N \\( -name A -o … \\) -prune -o -print`, in
330
- name order (not find's readdir order), links not followed, errors silent."""
331
- def walk(path, name, depth):
332
- if name in prune:
333
- return
334
- yield path
335
- if depth >= maxdepth:
336
- return
337
- try:
338
- with os.scandir(b_bytes(path)) as it:
339
- entries = list(it)
340
- except OSError:
341
- return
342
- # by name, not readdir order: which held file a refusal names must not depend
343
- # on the filesystem (ext4 here put .ai/policies first, the CI runner .ai/state)
344
- for e in sorted(entries, key=lambda e: e.name):
345
- try:
346
- is_d = e.is_dir(follow_symlinks=False)
347
- except OSError:
348
- is_d = False
349
- child = (path.rstrip("/") if path != "/" else "") + "/" + bytes_b(e.name)
350
- if is_d:
351
- yield from walk(child, bytes_b(e.name), depth + 1)
352
- elif bytes_b(e.name) not in prune:
353
- yield child
354
- base = start.rstrip("/").rsplit("/", 1)[-1] if start.rstrip("/") else "/"
355
- try:
356
- st = os.lstat(b_bytes(start))
357
- except OSError:
358
- return
359
- if stat.S_ISLNK(st.st_mode):
360
- if base not in prune:
361
- yield start # -P: a start that is a link is not entered
362
- return
363
- yield from walk(start, base, 0)
364
-
365
-
366
- def shell_segments(cmd):
367
- """The awk segmenter of shell_segments, character for character: the
368
- simple commands of a shell line, verb first, one per output line."""
369
- src = cmd + "\n"
370
- n = len(src)
371
- out = []
372
- cur = []
373
- stk, qst = [], []
374
- q = ""
375
- nh = 0
376
- hd, hs = [], []
377
- i = 0
378
-
379
- def emit():
380
- s = "".join(cur)
381
- cur.clear()
382
- while True:
383
- s = s.lstrip(" \t")
384
- if s == "":
385
- return
386
- qq = ""
387
- k = 0
388
- ln = len(s)
389
- while k < ln:
390
- c = s[k]
391
- if qq:
392
- if c == qq:
393
- qq = ""
394
- elif c in "\"'":
395
- qq = c
396
- elif c in " \t":
397
- break
398
- k += 1
399
- w = s[:k]
400
- if w in _KEYWORDS:
401
- s = s[k:]
402
- if w in _FLAGGED:
403
- while True:
404
- s = s.lstrip(" \t")
405
- if not s.startswith("-"):
406
- break
407
- j = 0
408
- while j < len(s) and s[j] not in " \t":
409
- j += 1
410
- s = s[j:]
411
- continue
412
- if _ASSIGN.match(w):
413
- s = s[k:]
414
- continue
415
- break
416
- out.append(s)
417
-
418
- def cur_ends(chars):
419
- return bool(cur) and cur[-1][-1:] in chars and cur[-1][-1:] != ""
420
-
421
- while i < n:
422
- c = src[i]
423
- d = src[i + 1] if i + 1 < n else ""
424
- if q == "s":
425
- j = src.find("'", i)
426
- k = src.find("\n", i)
427
- if j < 0:
428
- j = n
429
- if 0 <= k < j:
430
- cur.append(src[i:k] + " ")
431
- i = k + 1
432
- continue
433
- cur.append(src[i:j + 1])
434
- i = j + 1
435
- if j < n:
436
- q = ""
437
- continue
438
- if q == "a":
439
- m = _SPECIAL_A.search(src, i)
440
- j = m.start() if m else n
441
- if j > i:
442
- cur.append(src[i:j])
443
- i = j
444
- continue
445
- if c == "\\":
446
- cur.append(c + d)
447
- i += 2
448
- continue
449
- if c == "'":
450
- q = ""
451
- cur.append(" " if c == "\n" else c)
452
- i += 1
453
- continue
454
- if c == "\\":
455
- if d == "\n":
456
- i += 2
457
- continue
458
- cur.append(c + d)
459
- i += 2
460
- continue
461
- if q == "d":
462
- m = _SPECIAL_D.search(src, i)
463
- j = m.start() if m else n
464
- if j > i:
465
- cur.append(src[i:j])
466
- i = j
467
- continue
468
- if c == '"':
469
- q = ""
470
- cur.append(c)
471
- i += 1
472
- continue
473
- if c == "$" and d == "(":
474
- stk.append("p")
475
- qst.append(q)
476
- q = ""
477
- emit()
478
- i += 2
479
- continue
480
- if c == "`":
481
- stk.append("b")
482
- qst.append(q)
483
- q = ""
484
- emit()
485
- i += 1
486
- continue
487
- if c == "$":
488
- cur.append(c)
489
- i += 1
490
- continue
491
- cur.append(" " if c == "\n" else c)
492
- i += 1
493
- continue
494
- # outside quotes
495
- m = _SPECIAL_OUT.search(src, i)
496
- j = m.start() if m else n
497
- if j > i:
498
- cur.append(src[i:j])
499
- i = j
500
- continue
501
- if c == "'":
502
- q = "s"
503
- cur.append(c)
504
- i += 1
505
- continue
506
- if c == "$" and d == "'":
507
- q = "a"
508
- cur.append(c + d)
509
- i += 2
510
- continue
511
- if c == '"':
512
- q = "d"
513
- cur.append(c)
514
- i += 1
515
- continue
516
- if c == "#" and (not cur or "".join(cur) == "" or cur_ends(" \t")):
517
- k = src.find("\n", i)
518
- i = k if k >= 0 else n
519
- continue
520
- if c == "<" and d == "<" and src[i + 2:i + 3] == "<":
521
- cur.append("<<<")
522
- i += 3
523
- continue
524
- if (c == "$" and d == "(" and src[i + 2:i + 3] == "(") or (c == "(" and d == "("):
525
- j = i + (3 if c == "$" else 2)
526
- depth = 2
527
- while j < n and depth > 0:
528
- e = src[j]
529
- if e == "(":
530
- depth += 1
531
- elif e == ")":
532
- depth -= 1
533
- j += 1
534
- cur.append(src[i:j])
535
- i = j
536
- continue
537
- if c == "<" and d == "<":
538
- j = i + 2
539
- strip = 0
540
- if src[j:j + 1] == "-":
541
- strip = 1
542
- j += 1
543
- while src[j:j + 1] in (" ", "\t") and j < n:
544
- j += 1
545
- w = []
546
- wq = ""
547
- while j < n:
548
- e = src[j]
549
- if wq:
550
- if e == wq:
551
- wq = ""
552
- else:
553
- w.append(e)
554
- j += 1
555
- continue
556
- if e in "'\"":
557
- wq = e
558
- j += 1
559
- continue
560
- if e == "\\":
561
- w.append(src[j + 1:j + 2])
562
- j += 2
563
- continue
564
- if e in " \t\n;&|<>()":
565
- break
566
- w.append(e)
567
- j += 1
568
- word = "".join(w)
569
- if word:
570
- nh += 1
571
- hd.append(word)
572
- hs.append(strip)
573
- i = j
574
- continue
575
- if c == "\n":
576
- emit()
577
- i += 1
578
- for h in range(nh):
579
- while i < n:
580
- e = src.find("\n", i)
581
- line = src[i:e] if e >= 0 else src[i:]
582
- i = e + 1 if e >= 0 else n
583
- if hs[h]:
584
- line = line.lstrip("\t")
585
- if line == hd[h]:
586
- break
587
- nh = 0
588
- hd, hs = [], []
589
- continue
590
- if c == "$" and d == "(":
591
- stk.append("p")
592
- qst.append(q)
593
- emit()
594
- i += 2
595
- continue
596
- if c == "$":
597
- cur.append(c)
598
- i += 1
599
- continue
600
- if c == "`":
601
- if stk and stk[-1] == "b":
602
- stk.pop()
603
- q = qst.pop()
604
- else:
605
- stk.append("b")
606
- qst.append(q)
607
- emit()
608
- i += 1
609
- continue
610
- if c == "(":
611
- stk.append("p")
612
- qst.append(q)
613
- emit()
614
- i += 1
615
- continue
616
- if c == ")":
617
- if stk and stk[-1] == "p":
618
- stk.pop()
619
- q = qst.pop()
620
- emit()
621
- i += 1
622
- continue
623
- if c == "&" and (cur_ends("<>") or d == ">"):
624
- cur.append(c)
625
- i += 1
626
- continue
627
- if c in ";|&":
628
- emit()
629
- i += 1
630
- continue
631
- cur.append(c)
632
- i += 1
633
- emit()
634
- return chomp_all("".join(s + "\n" for s in out))
20
+ from guard import paths_bash, paths_classify, paths_human, paths_interp, paths_movers
21
+ from guard.common import (Deny, chomp_all, env_b, find_ai_root, is_dir, is_file, jq_tostring,
22
+ load_json_file, strip1)
23
+ from guard.paths_base import T, WHY_WORK_CAP
24
+ from guard.paths_fs import cd_physical, lexnorm
635
25
 
636
26
 
637
27
  class PathGuard: # pylint: disable=too-many-instance-attributes,too-many-public-methods
28
+ """The state of one tool call: the work counters against their caps and the clock, and the task
29
+ memo. Its methods live in the rule modules and are bound below."""
30
+
638
31
  def __init__(self, ctx):
639
32
  self.ctx = ctx
640
33
  self.m = ctx.matcher
@@ -705,116 +98,6 @@ class PathGuard: # pylint: disable=too-many-instance-
705
98
  self.task_active, self.task_id = active, task_id
706
99
  return self.task_active == "yes"
707
100
 
708
- # -------------------------------------------------------------- patterns
709
-
710
- def load_patterns(self):
711
- files = [f for f in (self.ctx.hook_dir + "/ai-path-guard-defaults.json",
712
- self.root + "/.ai/policies/path-guard.json") if is_file(f)]
713
- tagged = []
714
- for f in files:
715
- loaded = load_json_file(f)
716
- if loaded is None:
717
- continue
718
- for doc in loaded[0]:
719
- if doc is not None and not isinstance(doc, dict):
720
- continue
721
- for tag, key in (("d", "deny_patterns"), ("a", "allow_patterns"),
722
- ("p", "protected_config_patterns"), ("k", "task_protected_patterns")):
723
- v = doc.get(key) if doc else None
724
- for item in jq_iter(v if v is not None and v is not False else []):
725
- if is_jq_string(item) and item != "":
726
- tagged.extend((tag, T(piece)) for piece in item.split("\n"))
727
- key = self.loc.sort_key
728
- seen, lists = set(), {"d": "", "a": "", "p": "", "k": ""}
729
- for tag, pat in sorted(tagged, key=lambda t: (key(t[0]), key(t[1]))):
730
- k = (key(tag), key(pat))
731
- if k in seen:
732
- continue
733
- seen.add(k)
734
- lists[tag] += pat + "\n"
735
- self.deny_p = chomp_all(lists["d"])
736
- self.allow_p = chomp_all(lists["a"])
737
- self.prot_p = chomp_all(lists["p"])
738
- self.task_p = chomp_all(lists["k"])
739
-
740
- def build_loose(self):
741
- interesting, loose_all = [], []
742
- for pattern in herestring_lines(self.deny_p + "\n" + self.prot_p + "\n" + self.task_p):
743
- if not pattern:
744
- continue
745
- interesting.append(pattern + "\n")
746
- loose = pattern[5:] if pattern.startswith("(^|/)") else pattern
747
- loose = loose[1:] if loose.startswith("^") else loose
748
- loose = strip1(loose, "$")
749
- loose_all.append(loose.replace("$", TOKEN_END) + "\n")
750
- self.interesting = chomp_all("".join(interesting))
751
- self.loose = chomp_all("".join(loose_all))
752
- self.loose_joined = self.loose.replace("\n", "|")
753
-
754
- # -------------------------------------------------------------- anchoring
755
-
756
- def setup_anchors(self):
757
- self.roots, self.prefixes, self.renamed = [], [], []
758
-
759
- def anchor_root(d, prefix):
760
- if not d.startswith("/"):
761
- return
762
- norm = lexnorm(d)
763
- self.roots.append(norm)
764
- self.prefixes.append(prefix)
765
- self.renamed.append(bool(prefix) and norm.rsplit("/", 1)[-1] + "/" != prefix)
766
-
767
- anchor_root(self.root, "")
768
- anc = self.root.rsplit("/", 1)[0] if "/" in self.root else ""
769
- while anc:
770
- if is_dir(anc + "/.ai"):
771
- anchor_root(anc, "")
772
- anc = anc.rsplit("/", 1)[0] if "/" in anc else ""
773
- if self.root != "/" and is_dir("/.ai"):
774
- anchor_root("/", "")
775
- if self.home:
776
- anchor_root(self.home + "/.claude", ".claude/")
777
- anchor_root(self.home + "/.codex", ".codex/")
778
- ccd, cxh = env_b("CLAUDE_CONFIG_DIR"), env_b("CODEX_HOME")
779
- if ccd:
780
- anchor_root(ccd, ".claude/")
781
- if cxh:
782
- anchor_root(cxh, ".codex/")
783
- self.anch = {"PROT": anchor_list(self.prot_p), "TASK": anchor_list(self.task_p)}
784
-
785
- def anchor_rels(self, path, roots, rels):
786
- np = lexnorm(path)
787
- tail = "/" if path.endswith("/") or path.endswith("/.") else ""
788
- for i, r in enumerate(roots):
789
- if r == "/":
790
- rel = np[1:] if np.startswith("/") else np
791
- elif np == r:
792
- rel = ""
793
- elif np.startswith(r + "/"):
794
- rel = np[len(r) + 1:]
795
- else:
796
- continue
797
- rel = self.prefixes[i] + rel if rel else strip1(self.prefixes[i], "/")
798
- if rel:
799
- rels.append(rel + tail)
800
-
801
- def anchored_hit(self, kind, path, rels):
802
- for rx, orig, absolute in self.anch[kind]:
803
- if absolute:
804
- if self.m.ere_match(rx, path):
805
- return orig
806
- continue
807
- for r in rels:
808
- if self.m.ere_match(rx, r):
809
- return orig
810
- return None
811
-
812
- def unanchored_hit(self, kind, path):
813
- for _, orig, _ in self.anch[kind]:
814
- if self.m.ere_match(orig, path):
815
- return orig
816
- return None
817
-
818
101
  def phys_path(self, p):
819
102
  tail = "/" if p.endswith("/") or p.endswith("/.") else ""
820
103
  d = lexnorm(p)
@@ -827,439 +110,8 @@ class PathGuard: # pylint: disable=too-many-instance-
827
110
  return None
828
111
  return (strip1(pwd, "/") + rest + tail) or "/"
829
112
 
830
- # -------------------------------------------------------------- classify
831
-
832
- def classify(self, p):
833
- """(kind, pattern) or None: protected, task or sensitive."""
834
- m = self.m
835
- subject = p
836
- for i, r in enumerate(self.roots):
837
- if self.renamed[i] and p.startswith(r + "/"):
838
- subject += "\n" + self.prefixes[i] + p[len(r) + 1:]
839
- if not m.matches_joined(subject, self.interesting):
840
- return None
841
- if m.matches_any(self.allow_p, p) is not None:
842
- return None
843
- anchored = not (any(c in p for c in "$`~*?{[") or self.loc.length(p) > 4096)
844
- task_hit = None
845
- if not anchored:
846
- hit = self.unanchored_hit("PROT", p)
847
- if hit is not None:
848
- return ("protected", hit)
849
- if self.task_p:
850
- task_hit = self.unanchored_hit("TASK", p)
851
- else:
852
- rels = []
853
- self.anchor_rels(p, self.roots, rels)
854
- hit = self.anchored_hit("PROT", p, rels)
855
- if hit is not None:
856
- return ("protected", hit)
857
- task_hit = self.anchored_hit("TASK", p, rels)
858
- if task_hit is None and (self.unanchored_hit("PROT", p) is not None
859
- or self.unanchored_hit("TASK", p) is not None):
860
- resolved = self.phys_path(p)
861
- if resolved is not None:
862
- phys_roots = [cd_physical(r) or r for r in self.roots]
863
- rels = []
864
- self.anchor_rels(resolved, self.roots, rels)
865
- self.anchor_rels(resolved, phys_roots, rels)
866
- hit = self.anchored_hit("PROT", resolved, rels)
867
- if hit is not None:
868
- return ("protected", hit)
869
- task_hit = self.anchored_hit("TASK", resolved, rels)
870
- if task_hit is not None:
871
- return ("task", task_hit)
872
- hit = m.matches_any(self.deny_p, p)
873
- if hit is not None:
874
- return ("sensitive", hit)
875
- return None
876
-
877
- def check_path(self, literal, verb):
878
- a = abs_path(literal, self.cwd)
879
- real = real_path(literal, self.cwd)
880
- for cand in [a] if real == a else [a, real]:
881
- v = self.classify(cand)
882
- if v is None:
883
- continue
884
- kind, pattern = v
885
- if verb == "read" and kind != "sensitive":
886
- hit = self.m.matches_any(self.deny_p, cand)
887
- if hit is not None:
888
- raise Deny("Refusing to read a sensitive path: %s (matched: %s)%s"
889
- % (literal, hit, WHY_SENSITIVE))
890
- if kind == "protected":
891
- if verb == "read":
892
- continue
893
- raise Deny("Refusing to %s a RiskRails control file: %s (matched: %s)%s"
894
- % (verb, literal, pattern, WHY_PROTECTED))
895
- if kind == "task":
896
- if verb == "read" or not self.task_in_flight():
897
- continue
898
- raise Deny("Refusing to %s runtime configuration while task %s is in flight: %s (matched: %s)%s"
899
- % (verb, self.task_id, literal, pattern, WHY_TASK))
900
- raise Deny("Refusing to %s a sensitive path: %s (matched: %s)%s"
901
- % (verb, literal, pattern, WHY_SENSITIVE))
902
-
903
- # -------------------------------------------------------------- covers
904
-
905
- def cover_init(self):
906
- if self.cover_done:
907
- return
908
- self.cover_done = True
909
- parts = []
910
- for p in read_lines(self.prot_p + "\n" + self.task_p + "\n"):
911
- if not p:
912
- continue
913
- p = p[5:] if p.startswith("(^|/)") else p
914
- p = p[1:] if p.startswith("^") else p
915
- parts.append(p + "\n")
916
- self.cover_joined = chomp_all("".join(parts)).replace("\n", "|")
917
- home = self.home
918
- roots = []
919
- for r in (self.root + "/.ai", self.root + "/.claude", self.root + "/.codex",
920
- env_b("CLAUDE_CONFIG_DIR") or home + "/.claude", env_b("CODEX_HOME") or home + "/.codex"):
921
- got = self.realpath_m_arg(r)
922
- if got is not None:
923
- roots.append(got)
924
- self.cover_roots = chomp_all("".join(r + "\n" for r in self.sort_u(roots)))
925
-
926
- def realpath_m_arg(self, p):
927
- """One argument of `realpath -m`, as the line it prints, or None."""
928
- if p == "":
929
- return None
930
- full = p if p.startswith("/") else self.ctx.pwd.rstrip("/") + "/" + p
931
- try:
932
- return realpath_m(full)
933
- except LoopError:
934
- return None
935
-
936
- def sort_u(self, items):
937
- key = self.loc.sort_key
938
- out, seen = [], set()
939
- for item in sorted(items, key=key):
940
- k = key(item)
941
- if k not in seen:
942
- seen.add(k)
943
- out.append(item)
944
- return out
945
-
946
- def _roots_under(self, c):
947
- for r in herestring_lines(self.cover_roots):
948
- if r and (c == "/" or r == c or r.startswith(c + "/")) and exists(r):
949
- yield r
950
-
951
- def covers_work(self, real, lexical):
952
- c = strip1(real, "/") or "/"
953
- for _ in self._roots_under(c):
954
- self.work_cap("scan")
955
- return True
956
- if is_dir(c):
957
- self.work_cap("scan")
958
- return True
959
- return bool(lexical)
960
-
961
- def covers_protected(self, real):
962
- c = strip1(real, "/") or "/"
963
- for line in (c, c + "/"):
964
- if not self.m.ere_match(self.loose_joined, line):
965
- continue
966
- v = self.classify(line)
967
- if v and (v[0] == "protected" or (v[0] == "task" and self.task_in_flight(remember=False))):
968
- return strip1(line, "/")
969
- roots = list(self._roots_under(c))
970
- scan = [c] if is_dir(c) else []
971
- if not roots and not scan:
972
- return None
973
-
974
- def stream():
975
- for r in roots:
976
- yield from find_print(r, 3, _FIND_PRUNE_ROOTS)
977
- count = 0
978
- for s in scan:
979
- for path in find_print(s, 6, _FIND_PRUNE_SCAN):
980
- if count >= 20000:
981
- return
982
- count += 1
983
- yield path
984
-
985
- pats = self.cover_joined.split("\n")
986
- valid = all(self.m.ere.valid(pt) for pt in pats)
987
- picked = 0
988
- if not valid:
989
- return None
990
- for path in stream():
991
- for line in (path, path + "/"):
992
- if not any(self.m.search_c(pt, line) for pt in pats):
993
- continue
994
- picked += 1
995
- if picked > 50:
996
- return None
997
- v = self.classify(line)
998
- if v and (v[0] == "protected" or (v[0] == "task" and self.task_in_flight(remember=False))):
999
- return strip1(line, "/")
1000
- return None
1001
-
1002
- def loose_hits(self, lines):
1003
- """1-based numbers of the lines that match the loose patterns, bytewise."""
1004
- pats = self.loose_joined.split("\n")
1005
- if not all(self.m.ere.valid(pt) for pt in pats):
1006
- return []
1007
- return [n for n, line in enumerate(lines, 1) if any(self.m.search_c(pt, line) for pt in pats)]
1008
-
1009
- # -------------------------------------------------------------- the movers pass
1010
-
1011
- def movers(self, segs):
1012
- m = self.m
1013
- if not (segs and m.ere_match(MOVERS, segs)):
1014
- return
1015
- self.cover_init()
1016
- sed1 = m.ere.compile_ere(self._loc_pat(_SED1), self._mode(), capture=True).rx
1017
- sed2 = m.ere.compile_ere(self._loc_pat(_SED2), self._mode(), capture=True).rx
1018
- kept = []
1019
- for line in segs.split("\n"):
1020
- t = self._loc_text(line)
1021
- t = sed1.sub("", t) if sed1 else t
1022
- t = sed2.sub("", t) if sed2 else t
1023
- line = self._from_loc(t)
1024
- if m.search_c(MOVERS, line):
1025
- kept.append(line)
1026
- segments = chomp_all("".join(k + "\n" for k in kept))
1027
- st = MoverState()
1028
- cwd_slash = strip1(self.cwd, "/") + "/"
1029
- length = self.loc.length
1030
- mover_base = max(length(self.cwd), length(self.home)) + 2
1031
- pending = ""
1032
- for seg in herestring_lines(segments):
1033
- self.work_clock()
1034
- if not st.batch + length(seg) < MOVER_CHUNK:
1035
- self.mover_check(st)
1036
- st.batch += length(seg) + 1
1037
- operands = [w for w in re.split("[ \t]+", seg) if w]
1038
- if len(operands) <= 1:
1039
- continue
1040
- verb = operands[0]
1041
- if verb not in MOVER_VERBS:
1042
- continue
1043
- dest, sources, opts_done, want_dest = "", [], False, False
1044
- stop = False
1045
- for w in operands[1:]:
1046
- self.work_clock()
1047
- if length(w) > 4096:
1048
- if any(c in GLOBBY for c in w):
1049
- pending = ("Refusing a shell %s with a %d-character operand that holds a glob or "
1050
- "expansion character: the guard cannot check what it expands to."
1051
- % (verb, length(w)))
1052
- stop = True
1053
- break
1054
- if not opts_done and not want_dest:
1055
- if w.startswith("--target-directory="):
1056
- dest = "LONG-OPERAND"
1057
- continue
1058
- if w.startswith("-"):
1059
- continue
1060
- w = "LONG-OPERAND"
1061
- if want_dest:
1062
- want_dest = False
1063
- dest = w
1064
- continue
1065
- if not opts_done:
1066
- if w == "--":
1067
- opts_done = True
1068
- continue
1069
- if w in ("-t", "--target-directory"):
1070
- if verb not in REMOVE_VERBS:
1071
- want_dest = True
1072
- continue
1073
- if w.startswith("--target-directory="):
1074
- dest = w.split("=", 1)[1]
1075
- continue
1076
- if w.startswith("-"):
1077
- continue
1078
- w = w.lstrip("\"'").rstrip("\"'")
1079
- if w:
1080
- sources.append(w)
1081
- if stop:
1082
- break
1083
- dl = 0
1084
- if verb not in REMOVE_VERBS:
1085
- dl = length(dest)
1086
- if not dest and sources:
1087
- dl = length(sources[-1])
1088
- if st.seg_first and not (st.batch + (2 * len(sources) + 1) * (mover_base + dl) + 2 * length(seg)
1089
- < MOVER_CHUNK):
1090
- self.mover_check(st)
1091
- first = len(st.cands)
1092
- if verb in REMOVE_VERBS:
1093
- for src in sources:
1094
- st.cands.append(src)
1095
- st.covers.append("yes")
1096
- else:
1097
- if not dest:
1098
- if len(sources) <= 1:
1099
- continue
1100
- dest = sources.pop()
1101
- dest = dest.lstrip("\"'").rstrip("\"'")
1102
- if not dest:
1103
- continue
1104
- into = ""
1105
- for src in sources:
1106
- if verb in ("rsync", "cp") and (src.endswith("/") or src.endswith("/.")):
1107
- into = "yes"
1108
- st.cands.append(dest)
1109
- st.covers.append(into)
1110
- for src in sources:
1111
- src = strip1(strip1(src, "/"), "/.")
1112
- st.cands.append(strip1(dest, "/") + "/" + src.rsplit("/", 1)[-1])
1113
- st.covers.append("yes")
1114
- if verb == "mv":
1115
- st.cands.append(src)
1116
- st.covers.append("yes")
1117
- if len(st.cands) <= first:
1118
- continue
1119
- for k in range(first, len(st.cands)):
1120
- self.work_clock()
1121
- cand = st.cands[k]
1122
- if cand in ("~", "$HOME", "${HOME}"):
1123
- cand = self.home
1124
- elif cand.startswith("~/"):
1125
- cand = self.home + "/" + cand[2:]
1126
- elif cand.startswith("$HOME/"):
1127
- cand = self.home + "/" + cand[6:]
1128
- elif cand.startswith("${HOME}/"):
1129
- cand = self.home + "/" + cand[8:]
1130
- if not cand.startswith("/"):
1131
- cand = cwd_slash + cand
1132
- st.abss.append(cand)
1133
- st.batch += length(cand) + 1
1134
- st.seg_first.append(first)
1135
- st.seg_count.append(len(st.cands) - first)
1136
- st.seg_verb.append(verb)
1137
- self.mover_check(st)
1138
- if pending:
1139
- raise Deny(pending)
1140
-
1141
- def _mode(self):
1142
- return "u" if self.loc.utf8 else "b"
1143
-
1144
- def _loc_pat(self, p):
1145
- return self.loc.u(p) if self.loc.utf8 else p
1146
-
1147
- def _loc_text(self, s):
1148
- return self.loc.u(s) if self.loc.utf8 else s
1149
-
1150
- def _from_loc(self, t):
1151
- return t.encode("utf-8", "surrogateescape").decode("latin-1") if self.loc.utf8 else t
1152
-
1153
- def mover_resolve(self, st, first, end, one_at_a_time=False):
1154
- length = self.loc.length
1155
- size, cuts = 0, []
1156
- for k in range(first, end):
1157
- self.work_clock()
1158
- if not size < MOVER_CHUNK:
1159
- cuts.append(k)
1160
- size = 0
1161
- size += length(st.abss[k]) + 1
1162
- cuts.append(end)
1163
- at, got = first, []
1164
- for k in cuts:
1165
- self.work_clock()
1166
- args = st.abss[at:k]
1167
- had = len(got)
1168
- for a in args:
1169
- r = self.realpath_m_arg(a)
1170
- if r is not None:
1171
- got.append(r)
1172
- if len(got) - had != len(args):
1173
- if not one_at_a_time:
1174
- return False
1175
- got = []
1176
- for n in range(first, end):
1177
- self.work_cap("classify")
1178
- r = self.realpath_m_arg(st.abss[n])
1179
- got.append(r if r else st.abss[n])
1180
- break
1181
- at = k
1182
- for n in range(first, end):
1183
- st.reals[n] = got[n - first]
1184
- at = first
1185
- for k in cuts:
1186
- self.work_clock()
1187
- lines = []
1188
- for n in range(at, k):
1189
- norm = strip1(st.reals[n], "/") or "/"
1190
- lines += [st.abss[n] + " " + st.reals[n], norm, norm + "/"]
1191
- for r in self.loose_hits(lines):
1192
- st.hits.add(3 * at + r)
1193
- at = k
1194
- return True
1195
-
1196
- def mover_check(self, st):
1197
- per_seg = False
1198
- st.reals, st.hits = {}, set()
1199
- if st.abss and not self.mover_resolve(st, 0, len(st.abss)):
1200
- per_seg = True
1201
- for s, first in enumerate(st.seg_first):
1202
- last = first + st.seg_count[s]
1203
- verb = st.seg_verb[s]
1204
- if per_seg:
1205
- self.mover_resolve(st, first, last, one_at_a_time=True)
1206
- self.work_clock()
1207
- for i in range(first, last):
1208
- self.work_clock()
1209
- a, real, cand = st.abss[i], st.reals[i], st.cands[i]
1210
- how = "copy or move into"
1211
- if verb in REMOVE_VERBS:
1212
- how = "removal of"
1213
- elif verb == "mv" and i > first and a != strip1(st.abss[first], "/") + "/" + a.rsplit("/", 1)[-1]:
1214
- how = "move of"
1215
- verdict = None
1216
- lexical = (3 * i + 2) in st.hits or (3 * i + 3) in st.hits
1217
- if (3 * i + 1) in st.hits or (st.covers[i] == "yes" and lexical):
1218
- self.work_cap("classify")
1219
- if (3 * i + 1) in st.hits:
1220
- verdict = (self.classify(a) or self.classify(real)
1221
- or self.classify(real_path(a, self.cwd)))
1222
- kind = verdict[0] if verdict else ""
1223
- if kind == "protected":
1224
- raise Deny("Refusing a shell %s a RiskRails control file: %s%s" % (how, cand, WHY_PROTECTED))
1225
- if kind == "task" and self.task_in_flight():
1226
- raise Deny("Refusing a shell %s runtime configuration while task %s is in flight: %s%s"
1227
- % (how, self.task_id, cand, WHY_TASK))
1228
- if st.covers[i] != "yes":
1229
- continue
1230
- if not self.covers_work(real, lexical):
1231
- continue
1232
- found = self.covers_protected(real)
1233
- if found is None:
1234
- continue
1235
- verdict = self.classify(found) or self.classify(found + "/")
1236
- held = "" if found == strip1(real, "/") else " (holds %s)" % found
1237
- if verdict and verdict[0] == "protected":
1238
- raise Deny("Refusing a shell %s a directory that holds a RiskRails control file: %s%s%s"
1239
- % (how, cand, held, WHY_PROTECTED))
1240
- raise Deny("Refusing a shell %s a directory that holds runtime configuration while task %s is "
1241
- "in flight: %s%s%s" % (how, self.task_id, cand, held, WHY_TASK))
1242
- st.reset()
1243
-
1244
113
  # -------------------------------------------------------------- the check
1245
114
 
1246
- def confirm_delete(self, cmd):
1247
- if "--co" not in cmd:
1248
- return
1249
- joined = cmd.replace("\\\n", " ")
1250
- for line in herestring_lines(joined):
1251
- if self.loc.length(line) <= 16384:
1252
- continue
1253
- if "--ap" not in line:
1254
- continue
1255
- if "update" not in line and "$" not in line:
1256
- continue
1257
- raise Deny("Refusing a command line over 16384 characters that holds --co, --ap and update or a "
1258
- "variable: the --confirm-delete rule does not run its pattern on a line that long."
1259
- + WHY_CONFIRM_DELETE)
1260
- if self.m.ere_match(CONFIRM_RE, joined, need=("--co", "--ap")):
1261
- raise Deny("Refusing 'update.py --apply --confirm-delete' from an agent session." + WHY_CONFIRM_DELETE)
1262
-
1263
115
  def check(self):
1264
116
  p = self.p
1265
117
  if p.tool not in ("Read", "Edit", "Write", "MultiEdit", "NotebookEdit", "Bash"):
@@ -1289,254 +141,44 @@ class PathGuard: # pylint: disable=too-many-instance-
1289
141
  return
1290
142
  self.check_bash(unattended)
1291
143
 
1292
- def dialog_consent(self, cmd):
1293
- """(the dialog is on for this call, the command is the next live pick
1294
- of this session) — R36.11's pass-through. state.py spends the pick
1295
- after its own argv check (R36.10); the guard only lets it reach it."""
1296
- if not self.root or not exists(self.root + "/.ai/policies/dialog.json"):
1297
- return False, False
1298
- from datetime import datetime, timezone # pylint: disable=import-outside-toplevel
1299
- from guard import dialog # pylint: disable=import-outside-toplevel
1300
- root = os.fsdecode(b_bytes(self.root))
1301
- values = self.p.values
1302
- raw = values[0] if len(values) == 1 and isinstance(values[0], dict) else None
1303
- hook_file = os.path.join(os.fsdecode(b_bytes(self.ctx.hook_dir)), "ai-guard.py")
1304
- try:
1305
- if raw is None or not dialog.effective_switch(root, dialog.payload_runtime(raw, hook_file), raw):
1306
- return False, False
1307
- pick = dialog.next_live_pick(root, raw.get("session_id"), datetime.now(timezone.utc))
1308
- except Exception: # pylint: disable=broad-exception-caught
1309
- return True, False
1310
- command = b_bytes(cmd).decode("utf-8", "replace").strip()
1311
- return True, bool(pick and pick.get("label") == command)
1312
-
1313
- def inplace_edit(self, cmd):
1314
- """perl or ruby -i as a command: INPLACE_RE on the line less the segments of a text tool."""
1315
- m = self.m
1316
- if not m.ere_match(INPLACE_RE, cmd):
1317
- return False
1318
- if "<<" in cmd: # a heredoc body is read by whoever reads it
1319
- return True
1320
- segs = shell_segments(cmd).split("\n")
1321
- if "|" in cmd and any(shell_sink(sg) for sg in segs):
1322
- return True # a text tool's output may run as shell text
1323
- git_runs = any(t in cmd for t in _GIT_RUNS)
1324
- if git_runs and any(_tool_word(sg.split(None, 1)[0]) == "git" for sg in segs if sg.strip()):
1325
- return True # the assignment that runs a command is not in the segment
1326
- keep = [sg for sg in segs if not text_tool_segment(sg, git_runs)]
1327
- return m.ere_match(INPLACE_RE, "\n".join(keep))
1328
-
1329
- def interpreter_text(self, cmd):
1330
- """The text of an interpreter one-liner or an eval, scanned whole (H3)."""
1331
- m = self.m
1332
- is_eval = m.ere_match(EVAL_WORD_RE, cmd)
1333
- is_interp = m.ere_match(INTERP_RE, cmd)
1334
- if (is_eval or is_interp) and m.ere_match(SECRET_TEXT_RE, cmd):
1335
- raise Deny("Refusing an interpreter one-liner that references a sensitive path." + WHY_SENSITIVE)
1336
- if m.ere_match(CONTROL_TEXT_RE, cmd):
1337
- if is_eval or self.inplace_edit(cmd):
1338
- raise Deny("Refusing an interpreter one-liner that writes the task's own control files." + WHY_APPROVE)
1339
- if is_interp:
1340
- scan = _STD_WRITE.sub(" ", cmd.replace("stdout.write", " ").replace("stderr.write", " "))
1341
- pscan = cmd.replace("copy.copy(", " ")
1342
- if (m.ere_match(WRITE_CALL_RE, scan) or m.ere_match(WRITE_WORD_RE, cmd)
1343
- or (m.ere_match("Path", cmd)
1344
- and (m.ere_match(WRITE_PATHREPL_RE, cmd) or m.ere_match(WRITE_PATHCOPY_RE, pscan)))
1345
- or (m.ere_match("open|new|FileIO", cmd) and m.ere_match(WRITE_MODE_RE, cmd))
1346
- or m.ere_match(WRITE_REDIR_RE, cmd)):
1347
- raise Deny("Refusing an interpreter one-liner that writes the task's own control files."
1348
- + WHY_APPROVE)
1349
-
1350
- def check_bash(self, unattended): # pylint: disable=too-many-branches,too-many-statements
1351
- m = self.m
1352
- cmd = chomp_all(self.p.bash_command())
1353
- if not cmd:
1354
- raise Allow()
1355
- cmd_joined = cmd.replace("\\\n", " ")
1356
- length = self.loc.length
1357
- if length(cmd) > LONG_CMD and (m.search_c(LONG_TRIGGER_RE, cmd) or m.matches_joined(cmd, self.loose)):
1358
- raise Deny("Refusing a %d-character shell command that names a guarded path or a state.py or "
1359
- "update.py verb: write it to a file and run that, or split it." % length(cmd))
1360
- if m.ere_match(DIALOG_GATE_RE, cmd_joined):
1361
- raise Deny("Refusing to run hooks/dialog-gate.py from a shell: it records the human's answer to "
1362
- "the dialog, and only the runtime calls it, after the dialog tool. To answer a decision, "
1363
- "send the dialog ('state.py decision show'); a human settles one in their own terminal "
1364
- "('state.py decision settle --id D<n> --pick <n>'). See .ai/policies/safety.md.")
1365
- on, picked = self.dialog_consent(cmd)
1366
- if m.ere_match(APPROVE_RE, cmd_joined) and not unattended and self.task_in_flight() and not picked:
1367
- raise Deny("Refusing 'state.py approve' from an agent session." + WHY_APPROVE)
1368
- if on and not picked and not unattended and m.ere_match(PROFILE_SET_RE, cmd_joined):
1369
- raise Deny("Refusing 'state.py profile set' from an agent session." + WHY_PICK)
1370
- if not picked and not unattended and m.ere_match(HUMAN_BY_RE, cmd_joined):
1371
- raise Deny(T("Refusing a verdict with 'state.py … --by' from an agent session.")
1372
- + (WHY_PICK if on else WHY_HUMAN))
1373
- if not on and not picked and not unattended and m.ere_match(START_LIGHT_RE, cmd_joined):
1374
- raise Deny(T("Refusing 'state.py start --mode light … --by' from an agent session: lowering the "
1375
- "mode is the human's.") + WHY_HUMAN)
1376
- self.interpreter_text(cmd)
1377
-
1378
- # Top-level splitting only: the string a sh -c or an eval runs is not
1379
- # re-parsed (R22, SQ13; the bypass is accepted in known-risks.md).
1380
- segs = shell_segments(cmd) if m.ere_match(VERBS_RE, cmd) else ""
1381
- self.movers(segs)
1382
-
1383
- if not (m.matches_joined(cmd, self.loose) or m.ere_match(LN_RE, cmd)):
1384
- raise Allow()
1385
-
1386
- tokens = []
1387
- words = [w for w in re.split("[ \t\n]+", re.sub("[|;&()<>]", " ", cmd + "\n")) if w]
1388
- stripped = []
1389
- for w in words:
1390
- w = w.lstrip("\"'").rstrip("\"'")
1391
- if w:
1392
- stripped.append(w)
1393
- for token in self.sort_u(stripped):
1394
- self.work_clock()
1395
- if not token or token.startswith("-"):
1396
- continue
1397
- tokens.append(token)
1398
- short, abs_lines = [], []
1399
- for token in tokens:
1400
- self.work_clock()
1401
- if length(token) > 4096:
1402
- if any(c in GLOBBY for c in token):
1403
- raise Deny("Refusing a shell command with a %d-character argument that holds a glob or "
1404
- "expansion character: the guard cannot check what it expands to." % length(token))
1405
- after = token.split("=", 1)[1] if "=" in token else token
1406
- if m.ere_match(self.loose_joined, abs_path(token, self.cwd) + "\n" + abs_path(after, self.cwd)):
1407
- raise Deny("Refusing a shell command with a %d-character argument that matches a guarded "
1408
- "path pattern: no path that long names a file, and the guard does not check it."
1409
- % length(token))
1410
- continue
1411
- short.append(token)
1412
- abs_lines.append(token if token.startswith("/") else strip1(self.cwd, "/") + "/" + token)
1413
- exist = []
1414
- for i in range(len(short)):
1415
- self.work_clock()
1416
- if exists(abs_lines[i]):
1417
- exist.append(i)
1418
- resolved = []
1419
- if exist:
1420
- for i in exist:
1421
- r = realpath_strict(abs_lines[i])
1422
- if r is not None:
1423
- resolved.append(chomp_all(r))
1424
- if len(resolved) != len(exist):
1425
- resolved = []
1426
- for i in exist:
1427
- self.work_cap("token")
1428
- resolved.append(real_path(short[i], self.cwd))
1429
- real_lines = list(abs_lines)
1430
- for j, i in enumerate(exist):
1431
- real_lines[i] = resolved[j]
1432
- lines = []
1433
- for i, token in enumerate(short):
1434
- eq = abs_lines[i]
1435
- if "=/" in token:
1436
- eq = token.split("=", 1)[1]
1437
- elif "=" in token:
1438
- eq = strip1(self.cwd, "/") + "/" + token.split("=", 1)[1]
1439
- lines += [abs_lines[i], real_lines[i], eq]
1440
- thits = {(n - 1) // 3 for n in self.loose_hits(lines)}
1441
- self.work_clock()
1442
- for token in [short[i] for i in range(len(short)) if i in thits]:
1443
- self.work_cap("token")
1444
- verdict = self.classify(abs_path(token, self.cwd)) or self.classify(real_path(token, self.cwd))
1445
- if "=" in token and verdict is None:
1446
- verdict = self.classify(abs_path(token.split("=", 1)[1], self.cwd))
1447
- if verdict is None:
1448
- continue
1449
- kind, pattern = verdict
1450
- esc = ere_escape(token)
1451
- # Reading is refused for a secret only; a control file is read freely (R22),
1452
- # unless it also matches a secret pattern (.ai/state/x.pem).
1453
- secret = kind == "sensitive" or m.matches_any(self.deny_p, abs_path(token, self.cwd)) is not None
1454
- if secret and (m.ere_match(READERS + "[\"']?" + esc, cmd)
1455
- or m.ere_match(COPIERS + "[\"']?" + esc, cmd)
1456
- or m.ere_match("<[[:space:]]*[\"']?" + esc, cmd)):
1457
- raise Deny("Refusing a shell command that would expose a sensitive path: %s (matched: %s)%s"
1458
- % (token, pattern, WHY_SENSITIVE))
1459
- how = ""
1460
- if m.ere_match(">>?[[:space:]]*[\"']?" + esc, cmd):
1461
- how = "redirect into"
1462
- elif m.ere_match(FETCHERS + "[\"']?" + esc, cmd):
1463
- how = "command that writes"
1464
- for rx, what in ((WRITERS, "command that writes"), (MODES, "command that writes"),
1465
- (DDWRITE, "command that writes"), (INPLACE, "in-place edit of"),
1466
- (REMOVERS, "removal of")):
1467
- if not how and m.ere_match(rx + "[\"']?" + esc, segs):
1468
- how = what
1469
- if how:
1470
- if kind == "protected":
1471
- raise Deny("Refusing a shell %s a RiskRails control file: %s%s" % (how, token, WHY_PROTECTED))
1472
- if kind == "task" and self.task_in_flight():
1473
- raise Deny("Refusing a shell %s runtime configuration while task %s is in flight: %s%s"
1474
- % (how, self.task_id, token, WHY_TASK))
1475
-
1476
- if m.ere_match(LN_RE, cmd):
1477
- src = []
1478
- for line in herestring_lines(strip1(cmd, "\n")):
1479
- g = m.groups(LN_SRC_RE, line)
1480
- if g is not None:
1481
- src.append(g[3] + "\n")
1482
- src = chomp_all("".join(src))
1483
- if src and self.classify(abs_path(src, self.cwd)):
1484
- raise Deny("Refusing to create a symlink to a sensitive path: %s%s" % (src, WHY_SENSITIVE))
1485
-
1486
-
1487
- class MoverState: # pylint: disable=too-many-instance-attributes,too-few-public-methods
1488
- """One batch of the movers pass: candidates and the segments that own them."""
1489
-
1490
- def __init__(self):
1491
- self.reset()
1492
-
1493
- def reset(self):
1494
- self.cands, self.covers, self.abss = [], [], []
1495
- self.seg_first, self.seg_count, self.seg_verb = [], [], []
1496
- self.reals, self.hits = {}, set()
1497
- self.batch = 0
1498
-
1499
-
1500
- def lexnorm(path):
1501
- """lexnorm: //, /./ and /../ resolved by text, no trailing slash."""
1502
- out = []
1503
- rest = path[1:] if path.startswith("/") else path
1504
- for seg in (rest + "/").split("/"):
1505
- if seg in ("", "."):
1506
- continue
1507
- if seg == "..":
1508
- if out:
1509
- out.pop()
1510
- else:
1511
- out.append(seg)
1512
- return "/" + "/".join(out) if out else "/"
1513
-
1514
-
1515
- def cd_physical(d):
1516
- """$PWD after `cd -P -- "$d"`, or None when cd fails."""
1517
- try:
1518
- if not (is_dir(d) and os.access(b_bytes(d), os.X_OK)):
1519
- return None
1520
- return bytes_b(os.path.realpath(b_bytes(d)))
1521
- except (OSError, ValueError):
1522
- return None
1523
-
1524
-
1525
- def anchor_list(patterns):
1526
- """[(anchored regex, pattern as written, matched against the absolute path)]."""
1527
- out = []
1528
- for p in herestring_lines(patterns):
1529
- if not p:
1530
- continue
1531
- if p.startswith("(^|/)"):
1532
- out.append(("^" + p[5:], p, False))
1533
- elif p.startswith("^"):
1534
- out.append((p, p, True))
1535
- elif p.startswith("/"):
1536
- out.append(("^" + p[1:], p, False))
1537
- else:
1538
- out.append(("(^|/)(" + p + ")", p, False))
1539
- return out
144
+ # -------------------------------------------------------------- the method table: moved methods, bound here
145
+
146
+ # pattern loading, anchors and classification (paths_classify)
147
+ load_patterns = paths_classify.load_patterns
148
+ build_loose = paths_classify.build_loose
149
+ setup_anchors = paths_classify.setup_anchors
150
+ anchor_rels = paths_classify.anchor_rels
151
+ anchored_hit = paths_classify.anchored_hit
152
+ unanchored_hit = paths_classify.unanchored_hit
153
+ classify = paths_classify.classify
154
+ check_path = paths_classify.check_path
155
+
156
+ # the movers pass and its location helpers (paths_movers)
157
+ cover_init = paths_movers.cover_init
158
+ realpath_m_arg = paths_movers.realpath_m_arg
159
+ sort_u = paths_movers.sort_u
160
+ roots_under = paths_movers.roots_under
161
+ covers_work = paths_movers.covers_work
162
+ covers_protected = paths_movers.covers_protected
163
+ loose_hits = paths_movers.loose_hits
164
+ movers = paths_movers.movers
165
+ loc_mode = paths_movers.loc_mode
166
+ loc_pat = paths_movers.loc_pat
167
+ loc_text = paths_movers.loc_text
168
+ from_loc = paths_movers.from_loc
169
+ mover_resolve = paths_movers.mover_resolve
170
+ mover_check = paths_movers.mover_check
171
+
172
+ # the --confirm-delete rule and the dialog consent (paths_human)
173
+ confirm_delete = paths_human.confirm_delete
174
+ dialog_consent = paths_human.dialog_consent
175
+
176
+ # interpreter one-liners and in-place edits (paths_interp)
177
+ inplace_edit = paths_interp.inplace_edit
178
+ interpreter_text = paths_interp.interpreter_text
179
+
180
+ # the Bash rule (paths_bash)
181
+ check_bash = paths_bash.check_bash
1540
182
 
1541
183
 
1542
184
  def check(ctx):