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