riskrails 4.1.0 → 4.3.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +25 -15
- package/agents/ai-reviewer.md.tmpl +14 -1
- package/ai/agents/implementer.md +6 -0
- package/ai/agents/reviewer.md +8 -1
- package/bin/ansi.js +12 -0
- package/bin/menu.js +71 -12
- package/bin/riskrails.js +240 -14
- package/bin/uninstall.js +115 -0
- package/bin/wizard.js +72 -0
- package/hooks/AGENTS.md +0 -6
- package/hooks/CLAUDE.md +0 -6
- package/hooks/context-guard.py +11 -1
- package/hooks/dialog-gate.py +3 -2
- package/hooks/guard/decision.py +4 -3
- package/hooks/guard/dialog.py +12 -2
- package/hooks/guard/ere.py +29 -10
- package/hooks/guard/git.py +2 -1
- package/hooks/guard/paths.py +48 -1406
- package/hooks/guard/paths_base.py +66 -0
- package/hooks/guard/paths_bash.py +176 -0
- package/hooks/guard/paths_classify.py +205 -0
- package/hooks/guard/paths_fs.py +137 -0
- package/hooks/guard/paths_human.py +105 -0
- package/hooks/guard/paths_interp.py +162 -0
- package/hooks/guard/paths_movers.py +393 -0
- package/hooks/guard/paths_shell.py +288 -0
- package/hooks/runtime-gate.py +87 -0
- package/hooks/update_notice.py +23 -8
- package/package.json +1 -1
- package/skills/AGENTS.md +0 -6
- package/skills/CLAUDE.md +0 -6
- package/skills/ai-init/templates/.ai/policies/coding.md +9 -0
- package/skills/ai-init/templates/gitignore.snippet +2 -0
- package/skills/ai-task/memory_cleanup.py +213 -0
- package/skills/ai-task/memory_compact.py +25 -0
- package/skills/ai-task/memory_config.py +120 -0
- package/skills/ai-task/memory_export.py +246 -0
- package/skills/ai-task/memory_format.py +7 -3
- package/skills/ai-task/memory_hook.py +172 -0
- package/skills/ai-task/memory_io.py +122 -0
- package/skills/ai-task/memory_migrate.py +187 -0
- package/skills/ai-task/memory_packages.py +83 -0
- package/skills/ai-task/memory_risks.py +84 -0
- package/skills/ai-task/memory_status.py +76 -0
- package/skills/ai-task/memory_sync.py +20 -1481
- package/skills/ai-task/memory_verify.py +316 -0
- package/skills/ai-task/rr_state/cli.py +1 -0
- package/skills/ai-task/rr_state/close.py +2 -2
- package/skills/ai-task/rr_state/dialog.py +32 -7
- package/skills/ai-task/rr_state/dialog_cmd.py +38 -5
- package/skills/ai-task/rr_state/gate.py +10 -4
- package/skills/ai-task/rr_state/labels.py +33 -4
- package/skills/ai-task/rr_state/memory_cmd.py +84 -64
- package/skills/ai-task/rr_state/questions_cmd.py +5 -4
- package/skills/ai-task/rr_state/report.py +73 -10
- package/skills/ai-task/rr_state/report_cmd.py +2 -0
- package/skills/ai-task/rr_state/review.py +18 -14
- package/skills/ai-task/rr_state/start.py +12 -9
- package/skills/ai-task/rr_state/steps.py +16 -7
- package/skills/ai-task/rr_state/verify.py +17 -12
- package/skills/project-update/history/blobs/dac90781793b8a1c8e5f3e0307665b6394d922140c3831c5508d019f79e1ed0a +41 -0
- package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
- package/skills/project-update/history/index.json +8 -4
- package/skills/project-update/rr_update/__init__.py +1 -0
- package/skills/project-update/rr_update/apply.py +188 -0
- package/skills/project-update/rr_update/blocks.py +38 -0
- package/skills/project-update/rr_update/build.py +96 -0
- package/skills/project-update/rr_update/cleanup.py +194 -0
- package/skills/project-update/rr_update/cli.py +102 -0
- package/skills/project-update/rr_update/files.py +106 -0
- package/skills/project-update/rr_update/gate.py +60 -0
- package/skills/project-update/rr_update/hints.py +128 -0
- package/skills/project-update/rr_update/history.py +117 -0
- package/skills/project-update/rr_update/kept.py +120 -0
- package/skills/project-update/rr_update/paths.py +108 -0
- package/skills/project-update/rr_update/plan.py +123 -0
- package/skills/project-update/rr_update/report.py +51 -0
- package/skills/project-update/rr_update/rules.py +178 -0
- package/skills/project-update/rr_update/shared.py +74 -0
- package/skills/project-update/rr_update/siblings.py +37 -0
- package/skills/project-update/rr_update/weights.py +171 -0
- package/skills/project-update/update.py +8 -1682
- package/skills/rr-build/SKILL.md +5 -2
- package/skills/rr-setup/SKILL.md +4 -2
- package/skills/rr-setup/setup.py +13 -12
- package/skills/rr-ship/SKILL.md +2 -2
- package/skills/rr-spec/SKILL.md +6 -0
- package/skills/rr-status/SKILL.md +2 -0
- package/skills/rr-status/status.py +5 -3
package/hooks/guard/paths.py
CHANGED
|
@@ -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
|
|
19
|
-
import
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
from guard.
|
|
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
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
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):
|