riskrails 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +11 -11
  5. package/hooks/AGENTS.md +0 -6
  6. package/hooks/CLAUDE.md +0 -6
  7. package/hooks/guard/paths.py +48 -1406
  8. package/hooks/guard/paths_base.py +59 -0
  9. package/hooks/guard/paths_bash.py +176 -0
  10. package/hooks/guard/paths_classify.py +205 -0
  11. package/hooks/guard/paths_fs.py +137 -0
  12. package/hooks/guard/paths_human.py +105 -0
  13. package/hooks/guard/paths_interp.py +162 -0
  14. package/hooks/guard/paths_movers.py +393 -0
  15. package/hooks/guard/paths_shell.py +288 -0
  16. package/package.json +1 -1
  17. package/skills/AGENTS.md +0 -6
  18. package/skills/CLAUDE.md +0 -6
  19. package/skills/ai-init/templates/gitignore.snippet +2 -0
  20. package/skills/ai-task/memory_cleanup.py +213 -0
  21. package/skills/ai-task/memory_compact.py +25 -0
  22. package/skills/ai-task/memory_config.py +120 -0
  23. package/skills/ai-task/memory_export.py +246 -0
  24. package/skills/ai-task/memory_format.py +7 -3
  25. package/skills/ai-task/memory_hook.py +172 -0
  26. package/skills/ai-task/memory_io.py +122 -0
  27. package/skills/ai-task/memory_migrate.py +187 -0
  28. package/skills/ai-task/memory_packages.py +83 -0
  29. package/skills/ai-task/memory_risks.py +84 -0
  30. package/skills/ai-task/memory_status.py +76 -0
  31. package/skills/ai-task/memory_sync.py +20 -1481
  32. package/skills/ai-task/memory_verify.py +316 -0
  33. package/skills/ai-task/rr_state/cli.py +1 -0
  34. package/skills/ai-task/rr_state/close.py +2 -2
  35. package/skills/ai-task/rr_state/memory_cmd.py +84 -64
  36. package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
  37. package/skills/project-update/history/index.json +2 -1
  38. package/skills/project-update/rr_update/__init__.py +1 -0
  39. package/skills/project-update/rr_update/apply.py +188 -0
  40. package/skills/project-update/rr_update/blocks.py +38 -0
  41. package/skills/project-update/rr_update/build.py +96 -0
  42. package/skills/project-update/rr_update/cleanup.py +194 -0
  43. package/skills/project-update/rr_update/cli.py +102 -0
  44. package/skills/project-update/rr_update/files.py +106 -0
  45. package/skills/project-update/rr_update/gate.py +60 -0
  46. package/skills/project-update/rr_update/hints.py +128 -0
  47. package/skills/project-update/rr_update/history.py +117 -0
  48. package/skills/project-update/rr_update/kept.py +120 -0
  49. package/skills/project-update/rr_update/paths.py +108 -0
  50. package/skills/project-update/rr_update/plan.py +123 -0
  51. package/skills/project-update/rr_update/report.py +51 -0
  52. package/skills/project-update/rr_update/rules.py +178 -0
  53. package/skills/project-update/rr_update/shared.py +74 -0
  54. package/skills/project-update/rr_update/siblings.py +37 -0
  55. package/skills/project-update/rr_update/weights.py +171 -0
  56. package/skills/project-update/update.py +8 -1682
  57. package/skills/rr-setup/SKILL.md +4 -2
  58. package/skills/rr-setup/setup.py +13 -12
@@ -0,0 +1,59 @@
1
+ """The shared texts of the path guard: the text converter T, the regex fragments Q, END, VAR and
2
+ SEGMENT, and the deny messages (the WHY_* texts) that more than one rule module raises.
3
+
4
+ R16: every name here is core (no file, clock or subprocess access).
5
+ Moved from paths.py in WP5 (small-oop-modules), unchanged.
6
+ """
7
+ from guard.common import text_to_b
8
+
9
+
10
+ T = text_to_b
11
+
12
+ Q = "['\"]?"
13
+ END = "([[:space:]]|['\"]|$)"
14
+ VAR = "\\$\\{?[A-Za-z_][A-Za-z0-9_]*\\}?"
15
+
16
+ WHY_CONFIRM_DELETE = T(
17
+ "\n\n--confirm-delete is a human confirming an irreversible deletion, and the name\n"
18
+ "typed is the audit trail. A human runs the same command in a terminal\n"
19
+ "outside Claude Code or Codex (not through the ! prefix: that shell is the\n"
20
+ "agent's). An unattended run exports AI_UNATTENDED=1 in the launcher's environment, and the\n"
21
+ "record then says via: unattended. See skills/project-update/SKILL.md.")
22
+ WHY_SENSITIVE = T(
23
+ "\n\nProduction secrets and data dumps must not enter the model context.\n"
24
+ "If this path is genuinely safe (a .dist/.example file, a fixture), add a regex to\n"
25
+ "\"allow_patterns\" in .ai/policies/path-guard.json. See .ai/policies/security.md.")
26
+ WHY_PROTECTED = T(
27
+ "\n\nThese files are the guard configuration and the task state. State is written by\n"
28
+ "skills/ai-task/state.py, and during a schema migration by\n"
29
+ "skills/project-update/update.py; policy files are edited by a human outside an\n"
30
+ "agent run, so a change to them is reviewable. See .ai/policies/safety.md.")
31
+ WHY_TASK = T(
32
+ "\n\nWhile a task is in flight the runtime's own configuration is frozen: settings,\n"
33
+ "agent and skill definitions, commands, hooks, and the pipeline's policies and\n"
34
+ "workflows. A run that edits the rules it is being judged by is how scope and\n"
35
+ "review quietly get weaker. Finish or archive the task\n"
36
+ "(skills/ai-task/state.py close), then change this through /project-update or by\n"
37
+ "hand outside a run. See .ai/policies/safety.md.")
38
+ WHY_APPROVE = T(
39
+ "\n\nApproval happens outside the agent. A human runs, in their own terminal:\n"
40
+ " python3 <plugin>/skills/ai-task/state.py --root . approve --by \"<name>\"\n"
41
+ "or sets [Answer]: A on the gate question in .ai/reports/<task-id>/task.md\n"
42
+ "and tells the session to run state.py questions --sync. An unattended run\n"
43
+ "exports AI_UNATTENDED=1 in the launcher's environment, and the journal then\n"
44
+ "records the approval as unattended for ever. See .ai/policies/safety.md.")
45
+ WHY_WORK_CAP = T(
46
+ "This command names more paths than the guard can check within its timeout; split it into "
47
+ "smaller commands, or pass the list through a file and xargs.")
48
+
49
+ WHY_HUMAN = T(
50
+ "\n\nThis command is the human's, whether or not the dialog is on. A human runs it in their\n"
51
+ "own terminal, outside the agent session; an unattended run exports AI_UNATTENDED=1 in the\n"
52
+ "launcher's environment. See .ai/policies/safety.md.")
53
+ WHY_PICK = T(
54
+ "\n\nWith the dialog on, this command runs from an agent session only as the human's pick.\n"
55
+ "The command that needs it opened a decision: 'state.py decision show' prints the dialog;\n"
56
+ "send it and run the picked command verbatim, once. Otherwise the human runs it in their\n"
57
+ "own terminal. See .ai/policies/safety.md.")
58
+
59
+ SEGMENT = "^[[:space:]]*"
@@ -0,0 +1,176 @@
1
+ """The Bash rule of the path guard: the verb tables (readers, copiers, writers, removers, fetchers,
2
+ ln, dd) and check_bash, which walks the segments of one command and applies every other rule.
3
+
4
+ R16: shell (they read the file system, the clock or a subprocess): check_bash; 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 (Allow, Deny, abs_path, chomp_all, ere_escape, exists, herestring_lines,
9
+ real_path, realpath_strict, strip1)
10
+ from guard.paths_base import (SEGMENT, T, WHY_APPROVE, WHY_HUMAN, WHY_PICK, WHY_PROTECTED,
11
+ WHY_SENSITIVE, WHY_TASK)
12
+ from guard.paths_shell import shell_segments
13
+ from guard.paths_human import (APPROVE_RE, DIALOG_GATE_RE, HUMAN_BY_RE, PROFILE_SET_RE,
14
+ START_LIGHT_RE)
15
+ from guard.paths_movers import GLOBBY
16
+
17
+
18
+ VERBS_RE = ("(^|[^A-Za-z0-9_.-])(cp|mv|install|rsync|rm|rmdir|unlink|tee|truncate|shred|touch|ln|chmod|chown|"
19
+ "dd|sed|perl|ruby)([^A-Za-z0-9_.-]|$)")
20
+
21
+ READERS = ("(^|[|;&[:space:]])(cat|bat|less|more|head|tail|strings|xxd|od|hexdump|base64|nl|tac|rev)"
22
+ "([[:space:]]+-[^[:space:]]+)*[[:space:]]+")
23
+ COPIERS = "(^|[|;&[:space:]])(cp|mv|rsync|scp|install|tar|zip|curl|wget)([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+"
24
+ WRITERS = SEGMENT + "(tee|truncate|shred|touch|ln)([[:space:]]+-[^[:space:]]+)*[[:space:]]+"
25
+ REMOVERS = SEGMENT + "(rm|rmdir|unlink)([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+"
26
+ MODES = SEGMENT + "(chmod|chown)([[:space:]]+-[^[:space:]]+)*[[:space:]]+[^-][^[:space:]]*[[:space:]]+"
27
+ DDWRITE = SEGMENT + "dd([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+"
28
+ INPLACE = SEGMENT + "(sed|perl|ruby)([[:space:]]+-[^[:space:]]*i[^[:space:]]*)([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+"
29
+ LN_RE = "(^|[|;&[:space:]])ln([[:space:]]+-[^[:space:]]+)*[[:space:]]"
30
+ LN_SRC_RE = ".*(^|[|;&[:space:]])ln([[:space:]]+-[^[:space:]]+)*[[:space:]]+([^[:space:];&|]+).*"
31
+ # Words that make a long command worth a full check; past LONG_CMD the guard
32
+ # refuses rather than run its patterns over the text (R22, D3).
33
+ LONG_CMD = 4096
34
+ # Whole words only, so --cov, --config and approve_invoice do not trip it.
35
+ LONG_TRIGGER_RE = ("(^|[^A-Za-z0-9_])approve([^A-Za-z0-9_]|$)|--by(=|[[:space:]]|$)|"
36
+ "--con?f?i?r?m?-?(d?e?l?e?t?e?|c?l?e?a?n?u?p?)(=|[[:space:]]|$)|state\\.py|update\\.py")
37
+ # Tools that write a file named on their command line without a redirect; the
38
+ # movers do not parse them, so any protected path they name is refused (H1).
39
+ FETCHERS = "(^|[|;&[:space:]])(scp|tar|zip|unzip|curl|wget)([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+"
40
+
41
+
42
+ def check_bash(self, unattended): # pylint: disable=too-many-branches,too-many-statements
43
+ m = self.m
44
+ cmd = chomp_all(self.p.bash_command())
45
+ if not cmd:
46
+ raise Allow()
47
+ cmd_joined = cmd.replace("\\\n", " ")
48
+ length = self.loc.length
49
+ if length(cmd) > LONG_CMD and (m.search_c(LONG_TRIGGER_RE, cmd) or m.matches_joined(cmd, self.loose)):
50
+ raise Deny("Refusing a %d-character shell command that names a guarded path or a state.py or "
51
+ "update.py verb: write it to a file and run that, or split it." % length(cmd))
52
+ if m.ere_match(DIALOG_GATE_RE, cmd_joined):
53
+ raise Deny("Refusing to run hooks/dialog-gate.py from a shell: it records the human's answer to "
54
+ "the dialog, and only the runtime calls it, after the dialog tool. To answer a decision, "
55
+ "send the dialog ('state.py decision show'); a human settles one in their own terminal "
56
+ "('state.py decision settle --id D<n> --pick <n>'). See .ai/policies/safety.md.")
57
+ on, picked = self.dialog_consent(cmd)
58
+ if m.ere_match(APPROVE_RE, cmd_joined) and not unattended and self.task_in_flight() and not picked:
59
+ raise Deny("Refusing 'state.py approve' from an agent session." + WHY_APPROVE)
60
+ if on and not picked and not unattended and m.ere_match(PROFILE_SET_RE, cmd_joined):
61
+ raise Deny("Refusing 'state.py profile set' from an agent session." + WHY_PICK)
62
+ if not picked and not unattended and m.ere_match(HUMAN_BY_RE, cmd_joined):
63
+ raise Deny(T("Refusing a verdict with 'state.py … --by' from an agent session.")
64
+ + (WHY_PICK if on else WHY_HUMAN))
65
+ if not on and not picked and not unattended and m.ere_match(START_LIGHT_RE, cmd_joined):
66
+ raise Deny(T("Refusing 'state.py start --mode light … --by' from an agent session: lowering the "
67
+ "mode is the human's.") + WHY_HUMAN)
68
+ self.interpreter_text(cmd)
69
+
70
+ # Top-level splitting only: the string a sh -c or an eval runs is not
71
+ # re-parsed (R22, SQ13; the bypass is accepted in known-risks.md).
72
+ segs = shell_segments(cmd) if m.ere_match(VERBS_RE, cmd) else ""
73
+ self.movers(segs)
74
+
75
+ if not (m.matches_joined(cmd, self.loose) or m.ere_match(LN_RE, cmd)):
76
+ raise Allow()
77
+
78
+ tokens = []
79
+ words = [w for w in re.split("[ \t\n]+", re.sub("[|;&()<>]", " ", cmd + "\n")) if w]
80
+ stripped = []
81
+ for w in words:
82
+ w = w.lstrip("\"'").rstrip("\"'")
83
+ if w:
84
+ stripped.append(w)
85
+ for token in self.sort_u(stripped):
86
+ self.work_clock()
87
+ if not token or token.startswith("-"):
88
+ continue
89
+ tokens.append(token)
90
+ short, abs_lines = [], []
91
+ for token in tokens:
92
+ self.work_clock()
93
+ if length(token) > 4096:
94
+ if any(c in GLOBBY for c in token):
95
+ raise Deny("Refusing a shell command with a %d-character argument that holds a glob or "
96
+ "expansion character: the guard cannot check what it expands to." % length(token))
97
+ after = token.split("=", 1)[1] if "=" in token else token
98
+ if m.ere_match(self.loose_joined, abs_path(token, self.cwd) + "\n" + abs_path(after, self.cwd)):
99
+ raise Deny("Refusing a shell command with a %d-character argument that matches a guarded "
100
+ "path pattern: no path that long names a file, and the guard does not check it."
101
+ % length(token))
102
+ continue
103
+ short.append(token)
104
+ abs_lines.append(token if token.startswith("/") else strip1(self.cwd, "/") + "/" + token)
105
+ exist = []
106
+ for i in range(len(short)):
107
+ self.work_clock()
108
+ if exists(abs_lines[i]):
109
+ exist.append(i)
110
+ resolved = []
111
+ if exist:
112
+ for i in exist:
113
+ r = realpath_strict(abs_lines[i])
114
+ if r is not None:
115
+ resolved.append(chomp_all(r))
116
+ if len(resolved) != len(exist):
117
+ resolved = []
118
+ for i in exist:
119
+ self.work_cap("token")
120
+ resolved.append(real_path(short[i], self.cwd))
121
+ real_lines = list(abs_lines)
122
+ for j, i in enumerate(exist):
123
+ real_lines[i] = resolved[j]
124
+ lines = []
125
+ for i, token in enumerate(short):
126
+ eq = abs_lines[i]
127
+ if "=/" in token:
128
+ eq = token.split("=", 1)[1]
129
+ elif "=" in token:
130
+ eq = strip1(self.cwd, "/") + "/" + token.split("=", 1)[1]
131
+ lines += [abs_lines[i], real_lines[i], eq]
132
+ thits = {(n - 1) // 3 for n in self.loose_hits(lines)}
133
+ self.work_clock()
134
+ for token in [short[i] for i in range(len(short)) if i in thits]:
135
+ self.work_cap("token")
136
+ verdict = self.classify(abs_path(token, self.cwd)) or self.classify(real_path(token, self.cwd))
137
+ if "=" in token and verdict is None:
138
+ verdict = self.classify(abs_path(token.split("=", 1)[1], self.cwd))
139
+ if verdict is None:
140
+ continue
141
+ kind, pattern = verdict
142
+ esc = ere_escape(token)
143
+ # Reading is refused for a secret only; a control file is read freely (R22),
144
+ # unless it also matches a secret pattern (.ai/state/x.pem).
145
+ secret = kind == "sensitive" or m.matches_any(self.deny_p, abs_path(token, self.cwd)) is not None
146
+ if secret and (m.ere_match(READERS + "[\"']?" + esc, cmd)
147
+ or m.ere_match(COPIERS + "[\"']?" + esc, cmd)
148
+ or m.ere_match("<[[:space:]]*[\"']?" + esc, cmd)):
149
+ raise Deny("Refusing a shell command that would expose a sensitive path: %s (matched: %s)%s"
150
+ % (token, pattern, WHY_SENSITIVE))
151
+ how = ""
152
+ if m.ere_match(">>?[[:space:]]*[\"']?" + esc, cmd):
153
+ how = "redirect into"
154
+ elif m.ere_match(FETCHERS + "[\"']?" + esc, cmd):
155
+ how = "command that writes"
156
+ for rx, what in ((WRITERS, "command that writes"), (MODES, "command that writes"),
157
+ (DDWRITE, "command that writes"), (INPLACE, "in-place edit of"),
158
+ (REMOVERS, "removal of")):
159
+ if not how and m.ere_match(rx + "[\"']?" + esc, segs):
160
+ how = what
161
+ if how:
162
+ if kind == "protected":
163
+ raise Deny("Refusing a shell %s a RiskRails control file: %s%s" % (how, token, WHY_PROTECTED))
164
+ if kind == "task" and self.task_in_flight():
165
+ raise Deny("Refusing a shell %s runtime configuration while task %s is in flight: %s%s"
166
+ % (how, self.task_id, token, WHY_TASK))
167
+
168
+ if m.ere_match(LN_RE, cmd):
169
+ src = []
170
+ for line in herestring_lines(strip1(cmd, "\n")):
171
+ g = m.groups(LN_SRC_RE, line)
172
+ if g is not None:
173
+ src.append(g[3] + "\n")
174
+ src = chomp_all("".join(src))
175
+ if src and self.classify(abs_path(src, self.cwd)):
176
+ raise Deny("Refusing to create a symlink to a sensitive path: %s%s" % (src, WHY_SENSITIVE))
@@ -0,0 +1,205 @@
1
+ """Pattern loading and path classification: the sensitive and protected patterns, their anchors, and
2
+ classify and check_path, which decide what a path is.
3
+
4
+ R16: shell (they read the file system, the clock or a subprocess): load_patterns, setup_anchors, classify, check_path; every other name here is core.
5
+ phys_path, which classify calls through self, stays a PathGuard method in paths.py (it reads no self).
6
+ Moved from paths.py in WP5 (small-oop-modules), unchanged.
7
+ """
8
+ from guard.common import (Deny, abs_path, chomp_all, env_b, herestring_lines, is_dir, is_file,
9
+ is_jq_string, jq_iter, load_json_file, real_path, strip1)
10
+ from guard.paths_base import T, WHY_PROTECTED, WHY_SENSITIVE, WHY_TASK
11
+ from guard.paths_fs import anchor_list, cd_physical, lexnorm
12
+
13
+
14
+ TOKEN_END = "([[:space:]\"';&|)<>]|$)"
15
+
16
+
17
+ # -------------------------------------------------------------- patterns
18
+
19
+ def load_patterns(self):
20
+ files = [f for f in (self.ctx.hook_dir + "/ai-path-guard-defaults.json",
21
+ self.root + "/.ai/policies/path-guard.json") if is_file(f)]
22
+ tagged = []
23
+ for f in files:
24
+ loaded = load_json_file(f)
25
+ if loaded is None:
26
+ continue
27
+ for doc in loaded[0]:
28
+ if doc is not None and not isinstance(doc, dict):
29
+ continue
30
+ for tag, key in (("d", "deny_patterns"), ("a", "allow_patterns"),
31
+ ("p", "protected_config_patterns"), ("k", "task_protected_patterns")):
32
+ v = doc.get(key) if doc else None
33
+ for item in jq_iter(v if v is not None and v is not False else []):
34
+ if is_jq_string(item) and item != "":
35
+ tagged.extend((tag, T(piece)) for piece in item.split("\n"))
36
+ key = self.loc.sort_key
37
+ seen, lists = set(), {"d": "", "a": "", "p": "", "k": ""}
38
+ for tag, pat in sorted(tagged, key=lambda t: (key(t[0]), key(t[1]))):
39
+ k = (key(tag), key(pat))
40
+ if k in seen:
41
+ continue
42
+ seen.add(k)
43
+ lists[tag] += pat + "\n"
44
+ self.deny_p = chomp_all(lists["d"])
45
+ self.allow_p = chomp_all(lists["a"])
46
+ self.prot_p = chomp_all(lists["p"])
47
+ self.task_p = chomp_all(lists["k"])
48
+
49
+
50
+ def build_loose(self):
51
+ interesting, loose_all = [], []
52
+ for pattern in herestring_lines(self.deny_p + "\n" + self.prot_p + "\n" + self.task_p):
53
+ if not pattern:
54
+ continue
55
+ interesting.append(pattern + "\n")
56
+ loose = pattern[5:] if pattern.startswith("(^|/)") else pattern
57
+ loose = loose[1:] if loose.startswith("^") else loose
58
+ loose = strip1(loose, "$")
59
+ loose_all.append(loose.replace("$", TOKEN_END) + "\n")
60
+ self.interesting = chomp_all("".join(interesting))
61
+ self.loose = chomp_all("".join(loose_all))
62
+ self.loose_joined = self.loose.replace("\n", "|")
63
+
64
+
65
+ # -------------------------------------------------------------- anchoring
66
+
67
+ def setup_anchors(self):
68
+ self.roots, self.prefixes, self.renamed = [], [], []
69
+
70
+ def anchor_root(d, prefix):
71
+ if not d.startswith("/"):
72
+ return
73
+ norm = lexnorm(d)
74
+ self.roots.append(norm)
75
+ self.prefixes.append(prefix)
76
+ self.renamed.append(bool(prefix) and norm.rsplit("/", 1)[-1] + "/" != prefix)
77
+
78
+ anchor_root(self.root, "")
79
+ anc = self.root.rsplit("/", 1)[0] if "/" in self.root else ""
80
+ while anc:
81
+ if is_dir(anc + "/.ai"):
82
+ anchor_root(anc, "")
83
+ anc = anc.rsplit("/", 1)[0] if "/" in anc else ""
84
+ if self.root != "/" and is_dir("/.ai"):
85
+ anchor_root("/", "")
86
+ if self.home:
87
+ anchor_root(self.home + "/.claude", ".claude/")
88
+ anchor_root(self.home + "/.codex", ".codex/")
89
+ ccd, cxh = env_b("CLAUDE_CONFIG_DIR"), env_b("CODEX_HOME")
90
+ if ccd:
91
+ anchor_root(ccd, ".claude/")
92
+ if cxh:
93
+ anchor_root(cxh, ".codex/")
94
+ self.anch = {"PROT": anchor_list(self.prot_p), "TASK": anchor_list(self.task_p)}
95
+
96
+
97
+ def anchor_rels(self, path, roots, rels):
98
+ np = lexnorm(path)
99
+ tail = "/" if path.endswith("/") or path.endswith("/.") else ""
100
+ for i, r in enumerate(roots):
101
+ if r == "/":
102
+ rel = np[1:] if np.startswith("/") else np
103
+ elif np == r:
104
+ rel = ""
105
+ elif np.startswith(r + "/"):
106
+ rel = np[len(r) + 1:]
107
+ else:
108
+ continue
109
+ rel = self.prefixes[i] + rel if rel else strip1(self.prefixes[i], "/")
110
+ if rel:
111
+ rels.append(rel + tail)
112
+
113
+
114
+ def anchored_hit(self, kind, path, rels):
115
+ for rx, orig, absolute in self.anch[kind]:
116
+ if absolute:
117
+ if self.m.ere_match(rx, path):
118
+ return orig
119
+ continue
120
+ for r in rels:
121
+ if self.m.ere_match(rx, r):
122
+ return orig
123
+ return None
124
+
125
+
126
+ def unanchored_hit(self, kind, path):
127
+ for _, orig, _ in self.anch[kind]:
128
+ if self.m.ere_match(orig, path):
129
+ return orig
130
+ return None
131
+
132
+
133
+ # -------------------------------------------------------------- classify
134
+
135
+ def classify(self, p):
136
+ """(kind, pattern) or None: protected, task or sensitive."""
137
+ m = self.m
138
+ subject = p
139
+ for i, r in enumerate(self.roots):
140
+ if self.renamed[i] and p.startswith(r + "/"):
141
+ subject += "\n" + self.prefixes[i] + p[len(r) + 1:]
142
+ if not m.matches_joined(subject, self.interesting):
143
+ return None
144
+ if m.matches_any(self.allow_p, p) is not None:
145
+ return None
146
+ anchored = not (any(c in p for c in "$`~*?{[") or self.loc.length(p) > 4096)
147
+ task_hit = None
148
+ if not anchored:
149
+ hit = self.unanchored_hit("PROT", p)
150
+ if hit is not None:
151
+ return ("protected", hit)
152
+ if self.task_p:
153
+ task_hit = self.unanchored_hit("TASK", p)
154
+ else:
155
+ rels = []
156
+ self.anchor_rels(p, self.roots, rels)
157
+ hit = self.anchored_hit("PROT", p, rels)
158
+ if hit is not None:
159
+ return ("protected", hit)
160
+ task_hit = self.anchored_hit("TASK", p, rels)
161
+ if task_hit is None and (self.unanchored_hit("PROT", p) is not None
162
+ or self.unanchored_hit("TASK", p) is not None):
163
+ resolved = self.phys_path(p)
164
+ if resolved is not None:
165
+ phys_roots = [cd_physical(r) or r for r in self.roots]
166
+ rels = []
167
+ self.anchor_rels(resolved, self.roots, rels)
168
+ self.anchor_rels(resolved, phys_roots, rels)
169
+ hit = self.anchored_hit("PROT", resolved, rels)
170
+ if hit is not None:
171
+ return ("protected", hit)
172
+ task_hit = self.anchored_hit("TASK", resolved, rels)
173
+ if task_hit is not None:
174
+ return ("task", task_hit)
175
+ hit = m.matches_any(self.deny_p, p)
176
+ if hit is not None:
177
+ return ("sensitive", hit)
178
+ return None
179
+
180
+
181
+ def check_path(self, literal, verb):
182
+ a = abs_path(literal, self.cwd)
183
+ real = real_path(literal, self.cwd)
184
+ for cand in [a] if real == a else [a, real]:
185
+ v = self.classify(cand)
186
+ if v is None:
187
+ continue
188
+ kind, pattern = v
189
+ if verb == "read" and kind != "sensitive":
190
+ hit = self.m.matches_any(self.deny_p, cand)
191
+ if hit is not None:
192
+ raise Deny("Refusing to read a sensitive path: %s (matched: %s)%s"
193
+ % (literal, hit, WHY_SENSITIVE))
194
+ if kind == "protected":
195
+ if verb == "read":
196
+ continue
197
+ raise Deny("Refusing to %s a RiskRails control file: %s (matched: %s)%s"
198
+ % (verb, literal, pattern, WHY_PROTECTED))
199
+ if kind == "task":
200
+ if verb == "read" or not self.task_in_flight():
201
+ continue
202
+ raise Deny("Refusing to %s runtime configuration while task %s is in flight: %s (matched: %s)%s"
203
+ % (verb, self.task_id, literal, pattern, WHY_TASK))
204
+ raise Deny("Refusing to %s a sensitive path: %s (matched: %s)%s"
205
+ % (verb, literal, pattern, WHY_SENSITIVE))
@@ -0,0 +1,137 @@
1
+ """The path guard's stand-ins for the shell's file tools: realpath -m (realpath_m), find -prune -o
2
+ -print (find_print), the lexical clean-up (lexnorm), cd -P (cd_physical) and the anchored-pattern list
3
+ (anchor_list), with the LoopError they raise.
4
+
5
+ R16: shell (they read the file system, the clock or a subprocess): realpath_m, find_print, cd_physical; every other name here is core.
6
+ Moved from paths.py in WP5 (small-oop-modules), unchanged.
7
+ """
8
+ import os
9
+ import stat
10
+ from guard.common import b_bytes, bytes_b, herestring_lines, is_dir
11
+
12
+
13
+ class LoopError(OSError):
14
+ """A path that does not resolve: realpath prints an error, not a line."""
15
+
16
+
17
+ def realpath_m(path):
18
+ """`realpath -m` of one absolute path: every link that exists resolved,
19
+ what does not exist appended as written, `..` after resolution; a symlink
20
+ loop raises LoopError."""
21
+ seen = {}
22
+
23
+ def join(base, rest):
24
+ if rest.startswith(b"/"):
25
+ base = b"/"
26
+ for name in rest.split(b"/"):
27
+ if name in (b"", b"."):
28
+ continue
29
+ if name == b"..":
30
+ base = os.path.dirname(base) if base != b"/" else base
31
+ continue
32
+ new = base.rstrip(b"/") + b"/" + name
33
+ try:
34
+ is_link = stat.S_ISLNK(os.lstat(new).st_mode)
35
+ except OSError:
36
+ is_link = False
37
+ if not is_link:
38
+ base = new
39
+ continue
40
+ if new in seen:
41
+ if seen[new] is None:
42
+ raise LoopError()
43
+ base = seen[new]
44
+ continue
45
+ seen[new] = None
46
+ try:
47
+ target = os.readlink(new)
48
+ except OSError as exc:
49
+ raise LoopError() from exc
50
+ base = join(base, target)
51
+ seen[new] = base
52
+ return base
53
+
54
+ try:
55
+ return bytes_b(join(b"/", b_bytes(path)))
56
+ except RecursionError as exc:
57
+ raise LoopError() from exc
58
+
59
+
60
+ def find_print(start, maxdepth, prune):
61
+ """`find START -maxdepth N \\( -name A -o … \\) -prune -o -print`, in
62
+ name order (not find's readdir order), links not followed, errors silent."""
63
+ def walk(path, name, depth):
64
+ if name in prune:
65
+ return
66
+ yield path
67
+ if depth >= maxdepth:
68
+ return
69
+ try:
70
+ with os.scandir(b_bytes(path)) as it:
71
+ entries = list(it)
72
+ except OSError:
73
+ return
74
+ # by name, not readdir order: which held file a refusal names must not depend
75
+ # on the filesystem (ext4 here put .ai/policies first, the CI runner .ai/state)
76
+ for e in sorted(entries, key=lambda e: e.name):
77
+ try:
78
+ is_d = e.is_dir(follow_symlinks=False)
79
+ except OSError:
80
+ is_d = False
81
+ child = (path.rstrip("/") if path != "/" else "") + "/" + bytes_b(e.name)
82
+ if is_d:
83
+ yield from walk(child, bytes_b(e.name), depth + 1)
84
+ elif bytes_b(e.name) not in prune:
85
+ yield child
86
+ base = start.rstrip("/").rsplit("/", 1)[-1] if start.rstrip("/") else "/"
87
+ try:
88
+ st = os.lstat(b_bytes(start))
89
+ except OSError:
90
+ return
91
+ if stat.S_ISLNK(st.st_mode):
92
+ if base not in prune:
93
+ yield start # -P: a start that is a link is not entered
94
+ return
95
+ yield from walk(start, base, 0)
96
+
97
+
98
+ def lexnorm(path):
99
+ """lexnorm: //, /./ and /../ resolved by text, no trailing slash."""
100
+ out = []
101
+ rest = path[1:] if path.startswith("/") else path
102
+ for seg in (rest + "/").split("/"):
103
+ if seg in ("", "."):
104
+ continue
105
+ if seg == "..":
106
+ if out:
107
+ out.pop()
108
+ else:
109
+ out.append(seg)
110
+ return "/" + "/".join(out) if out else "/"
111
+
112
+
113
+ def cd_physical(d):
114
+ """$PWD after `cd -P -- "$d"`, or None when cd fails."""
115
+ try:
116
+ if not (is_dir(d) and os.access(b_bytes(d), os.X_OK)):
117
+ return None
118
+ return bytes_b(os.path.realpath(b_bytes(d)))
119
+ except (OSError, ValueError):
120
+ return None
121
+
122
+
123
+ def anchor_list(patterns):
124
+ """[(anchored regex, pattern as written, matched against the absolute path)]."""
125
+ out = []
126
+ for p in herestring_lines(patterns):
127
+ if not p:
128
+ continue
129
+ if p.startswith("(^|/)"):
130
+ out.append(("^" + p[5:], p, False))
131
+ elif p.startswith("^"):
132
+ out.append((p, p, True))
133
+ elif p.startswith("/"):
134
+ out.append(("^" + p[1:], p, False))
135
+ else:
136
+ out.append(("(^|/)(" + p + ")", p, False))
137
+ return out