riskrails 3.1.0 → 3.2.2

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 (53) 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 +12 -11
  5. package/bin/riskrails.js +109 -18
  6. package/hooks/ai-path-guard-defaults.json +3 -2
  7. package/hooks/context-guard.py +14 -11
  8. package/hooks/dialog-gate.py +1 -1
  9. package/hooks/guard/dialog.py +107 -27
  10. package/hooks/guard/git.py +12 -3
  11. package/hooks/guard/paths.py +109 -7
  12. package/hooks/project-scaffold.py +2 -1
  13. package/hooks/runtime-gate.py +128 -27
  14. package/hooks/update_notice.py +3 -3
  15. package/package.json +1 -1
  16. package/skills/ai-init/templates/.ai/state/README.md +4 -4
  17. package/skills/ai-task/rr_state/__init__.py +1 -0
  18. package/skills/ai-task/rr_state/cli.py +210 -0
  19. package/skills/ai-task/rr_state/close.py +171 -0
  20. package/skills/ai-task/rr_state/dialog.py +541 -0
  21. package/skills/ai-task/rr_state/dialog_cmd.py +164 -0
  22. package/skills/ai-task/rr_state/fs.py +220 -0
  23. package/skills/ai-task/rr_state/gate.py +125 -0
  24. package/skills/ai-task/rr_state/gate_cmd.py +128 -0
  25. package/skills/ai-task/rr_state/handoff.py +226 -0
  26. package/skills/ai-task/rr_state/journal.py +126 -0
  27. package/skills/ai-task/rr_state/labels.py +99 -0
  28. package/skills/ai-task/rr_state/ledger.py +145 -0
  29. package/skills/ai-task/rr_state/locks.py +84 -0
  30. package/skills/ai-task/rr_state/memory_cmd.py +310 -0
  31. package/skills/ai-task/rr_state/policy.py +378 -0
  32. package/skills/ai-task/rr_state/profile.py +373 -0
  33. package/skills/ai-task/rr_state/questions.py +336 -0
  34. package/skills/ai-task/rr_state/questions_cmd.py +321 -0
  35. package/skills/ai-task/rr_state/report.py +271 -0
  36. package/skills/ai-task/rr_state/report_cmd.py +230 -0
  37. package/skills/ai-task/rr_state/review.py +165 -0
  38. package/skills/ai-task/rr_state/runtime.py +80 -0
  39. package/skills/ai-task/rr_state/start.py +257 -0
  40. package/skills/ai-task/rr_state/steps.py +131 -0
  41. package/skills/ai-task/rr_state/task.py +396 -0
  42. package/skills/ai-task/rr_state/verify.py +231 -0
  43. package/skills/ai-task/sensors.py +16 -3
  44. package/skills/ai-task/state.py +24 -5161
  45. package/skills/project-update/adopt.py +38 -24
  46. package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
  47. package/skills/project-update/history/index.json +2 -1
  48. package/skills/project-update/migrations/0003_context_diet.py +1 -1
  49. package/skills/project-update/migrations/0007_weight_model.py +130 -23
  50. package/skills/project-update/migrations/README.md +16 -1
  51. package/skills/project-update/update.py +202 -26
  52. package/skills/rr-setup/SKILL.md +9 -2
  53. package/skills/rr-status/status.py +36 -9
@@ -7,11 +7,16 @@ tests/test_dialog_contract.py feeds both the same fixtures, so they cannot
7
7
  drift. Nothing here imports state.py: hooks run under `python3 -I -S`, stdlib
8
8
  only.
9
9
 
10
- Off is today: with .ai/policies/dialog.json missing or not `enabled: true`,
11
- no AI_UNATTENDED, the runtime's verified dialog tool, the main session and a
12
- session id all required, effective_switch() is False and no caller writes or
13
- decides anything (R36.1, R36.2). Readers fail open: a missing or unreadable
14
- file reads as "nothing open, nothing picked".
10
+ The switch has three states (switch_state). Off is today: with
11
+ .ai/policies/dialog.json missing or not `enabled: true`, AI_UNATTENDED set or
12
+ the runtime's verified dialog tool missing, no caller writes or decides
13
+ anything (R36.1, R36.2). Closed: the project's switch is on but the call
14
+ cannot be bound to a session that can answer a dialog (a subagent, no session
15
+ id, a session id that is not the hook environment's own); the dialog and the
16
+ ledger treat it like off, the git consent rule refuses (terminal route). On:
17
+ the main session with a session id. effective_switch() is True only for on.
18
+ Readers fail open: a missing or unreadable file reads as "nothing open,
19
+ nothing picked".
15
20
  """
16
21
  import contextlib
17
22
  import json
@@ -28,6 +33,10 @@ LOCK_STALE_S = 2.0
28
33
  LINE_MAX = 4096 # one write; O_APPEND keeps a line whole up to this
29
34
 
30
35
  DIALOG_TOOL = {"claude": "AskUserQuestion", "codex": "request_user_input"}
36
+ # The runtime's own session variable; the same mapping as state.py's. A hook's
37
+ # environment carries it for Claude only (spike 2026-10-31: no CODEX_* variable
38
+ # reaches a Codex hook), so it is compared only when present and non-empty.
39
+ SESSION_ENV = {"claude": "CLAUDE_CODE_SESSION_ID", "codex": "CODEX_THREAD_ID"}
31
40
  LEDGER_TYPES = {"offer", "pick", "use", "void", "decline", "settle"}
32
41
  STOP_BLOCK = "stop_block" # the Stop loop breaker's count; no reader of consent reads it
33
42
  LABEL_FORBIDDEN = (";", "&", "|", "\n", "\r", "`", "$(", "<", ">")
@@ -63,13 +72,39 @@ def canonical(obj):
63
72
 
64
73
 
65
74
  # -- the switch ---------------------------------------------------------------
75
+ JSON_DEPTH_MAX = 64 # one nesting cap for both switches, not the stack's
76
+
77
+
78
+ def json_loads_capped(text):
79
+ """json.loads(text), except that a text nested deeper than JSON_DEPTH_MAX is
80
+ malformed (ValueError). The parser's own limit moves with the stack depth, so
81
+ the hook and state.py could disagree; this scan does not recurse."""
82
+ depth, in_str, esc = 0, False, False
83
+ for ch in text:
84
+ if in_str:
85
+ if esc:
86
+ esc = False
87
+ elif ch == "\\":
88
+ esc = True
89
+ elif ch == '"':
90
+ in_str = False
91
+ elif ch == '"':
92
+ in_str = True
93
+ elif ch in "[{":
94
+ depth += 1
95
+ if depth > JSON_DEPTH_MAX:
96
+ raise ValueError("JSON nested deeper than %d" % JSON_DEPTH_MAX)
97
+ elif ch in "]}":
98
+ depth -= 1
99
+ return json.loads(text)
100
+
66
101
 
67
102
  def dialog_policy(root):
68
103
  """.ai/policies/dialog.json when it says enabled: true, else None."""
69
104
  try:
70
105
  with open(os.path.join(root, ".ai", "policies", "dialog.json"), encoding="utf-8") as fh:
71
- data = json.load(fh)
72
- except (OSError, ValueError, TypeError):
106
+ data = json_loads_capped(fh.read())
107
+ except (OSError, ValueError, TypeError, RecursionError):
73
108
  return None
74
109
  if not isinstance(data, dict) or data.get("enabled") is not True:
75
110
  return None
@@ -79,7 +114,29 @@ def dialog_policy(root):
79
114
  value = value if isinstance(value, int) and not isinstance(value, bool) else default
80
115
  return min(high, max(low, value))
81
116
  return {"window_seconds": bounded("window_seconds", *WINDOW),
82
- "stop_blocks_max": bounded("stop_blocks_max", STOP_BLOCKS_MAX, 0, STOP_BLOCKS_MAX)}
117
+ "stop_blocks_max": bounded("stop_blocks_max", STOP_BLOCKS_MAX, 0, STOP_BLOCKS_MAX),
118
+ "background_sessions": data.get("background_sessions") is True}
119
+
120
+
121
+ def ai_unattended():
122
+ """AI_UNATTENDED is set when it is non-empty and not 0, false or no (case
123
+ and blanks ignored): one rule in every reader (risk 0587)."""
124
+ return os.environ.get("AI_UNATTENDED", "").strip().lower() not in ("", "0", "false", "no")
125
+
126
+
127
+ def unattended(policy):
128
+ """True when the run is declared unattended: AI_UNATTENDED always; the
129
+ runtime's own marker (CLAUDE_CODE_SESSION_ATTENDED=0: a background job,
130
+ `claude -p`, the SDK) unless the project opts in with background_sessions:
131
+ true, for background jobs of the CLI a human is still chatting in. The
132
+ opt-in never covers an SDK entrypoint (CLAUDE_CODE_ENTRYPOINT sdk-*:
133
+ `claude -p`, an SDK host — nobody there answers the dialog; risk ffae).
134
+ state.py's dialog_on() keeps the same rule."""
135
+ if ai_unattended():
136
+ return True
137
+ background = policy.get("background_sessions") and \
138
+ not os.environ.get("CLAUDE_CODE_ENTRYPOINT", "").startswith("sdk-")
139
+ return os.environ.get("CLAUDE_CODE_SESSION_ATTENDED") == "0" and not background
83
140
 
84
141
 
85
142
  def runtime_home(runtime):
@@ -94,8 +151,8 @@ def dialog_tool(runtime):
94
151
  Only an installer from 7b on writes dialog.tool, always under riskrails/."""
95
152
  try:
96
153
  with open(os.path.join(runtime_home(runtime), "riskrails", "profile.json"), encoding="utf-8") as fh:
97
- tool = (json.load(fh).get("dialog") or {}).get("tool")
98
- except (OSError, ValueError, AttributeError):
154
+ tool = (json_loads_capped(fh.read()).get("dialog") or {}).get("tool")
155
+ except (OSError, ValueError, AttributeError, RecursionError):
99
156
  return None
100
157
  return tool if runtime in DIALOG_TOOL and tool == DIALOG_TOOL[runtime] else None
101
158
 
@@ -111,18 +168,28 @@ def payload_runtime(payload, hook_file):
111
168
  return "codex" if "%s.codex%s" % (os.sep, os.sep) in os.path.abspath(hook_file) else "claude"
112
169
 
113
170
 
171
+ def switch_state(root, runtime, payload):
172
+ """"off", "closed" or "on". Off is today's route: no root, the project's
173
+ switch not on, AI_UNATTENDED or the runtime's unattended marker set, or no
174
+ verified dialog tool. Closed: the switch is on but this call cannot be bound
175
+ to a session that can answer a dialog: a payload that is not an object, a
176
+ subagent (agent_id), no session id, or a session id other than the one the
177
+ hook's own environment carries. On: otherwise."""
178
+ policy = dialog_policy(root) if root else None
179
+ if policy is None or unattended(policy):
180
+ return "off"
181
+ if dialog_tool(runtime) is None:
182
+ return "off"
183
+ if not isinstance(payload, dict) or payload.get("agent_id") or not payload.get("session_id"):
184
+ return "closed"
185
+ own = os.environ.get(SESSION_ENV.get(runtime, ""))
186
+ return "closed" if own and own != payload.get("session_id") else "on"
187
+
188
+
114
189
  def effective_switch(root, runtime, payload):
115
- """On only when the project says so, nobody declared the run unattended,
116
- the runtime's dialog tool is the verified one, this is the main session
117
- (no agent_id: a subagent cannot answer a dialog) and the payload has a
118
- session id to bind a pick to. Every other combination is today's route."""
119
- if not root or dialog_policy(root) is None:
120
- return False
121
- if os.environ.get("AI_UNATTENDED") or os.environ.get("CLAUDE_CODE_SESSION_ATTENDED") == "0":
122
- return False
123
- if dialog_tool(runtime) is None or not isinstance(payload, dict):
124
- return False
125
- return not payload.get("agent_id") and bool(payload.get("session_id"))
190
+ """True only when switch_state() is "on"; "off" and "closed" are both no
191
+ dialog, no decision and no ledger write for every caller."""
192
+ return switch_state(root, runtime, payload) == "on"
126
193
 
127
194
 
128
195
  # -- labels -------------------------------------------------------------------
@@ -180,7 +247,7 @@ def read_decisions(root):
180
247
  try:
181
248
  with open(os.path.join(root, ".ai", "state", "decision.json"), encoding="utf-8") as fh:
182
249
  data = json.load(fh)
183
- except (OSError, ValueError, TypeError):
250
+ except (OSError, ValueError, TypeError, RecursionError):
184
251
  return []
185
252
  opened = data.get("open") if isinstance(data, dict) else None
186
253
  return [d for d in opened if isinstance(d, dict) and d.get("id")] if isinstance(opened, list) else []
@@ -198,6 +265,18 @@ def decision_parts(decision):
198
265
  return [(options, bool(decision.get("multi_select")))]
199
266
 
200
267
 
268
+ QUESTION_MAX = 1200 # state.py's
269
+
270
+
271
+ def part_question(question, i, n):
272
+ """state.py's part_question: part i of n of a split decision is
273
+ "<question> (i of n)", the question cut so the whole fits QUESTION_MAX."""
274
+ if n < 2:
275
+ return question
276
+ suffix = " (%d of %d)" % (i, n)
277
+ return (question or "")[:QUESTION_MAX - len(suffix)] + suffix
278
+
279
+
201
280
  def offered(decisions, runtime):
202
281
  """What a dialog call for these decisions carries, question by question:
203
282
  (decision, options, the question as sent) — state.py's dialog_payload."""
@@ -205,7 +284,8 @@ def offered(decisions, runtime):
205
284
  for decision in decisions:
206
285
  parts = decision_parts(decision)
207
286
  for i, (options, multi) in enumerate(parts, 1):
208
- item = {"question": decision.get("question"), "header": decision.get("header"),
287
+ item = {"question": part_question(decision.get("question"), i, len(parts)),
288
+ "header": decision.get("header"),
209
289
  "options": [{"label": o.get("label"), "description": o.get("description") or (
210
290
  "the session runs exactly this, once" if o.get("kind") == "command" else "nothing runs")}
211
291
  for o in options]}
@@ -248,14 +328,14 @@ def adapt_response(runtime, payload):
248
328
 
249
329
  Claude: answers keyed by question text, a multi-select's labels joined
250
330
  with ", "; two questions with one text cannot be told apart, so both read
251
- as unanswerable. Codex: tool_response is a JSON string,
331
+ as unanswerable (a split decision's parts are numbered "(i of n)" to differ). Codex: tool_response is a JSON string,
252
332
  answers[<id>].answers is a list; "None of the above" and "user_note: …"
253
333
  are items like any other, and match no label."""
254
334
  questions = response_questions(runtime, payload)
255
335
  if runtime == "codex":
256
336
  try:
257
337
  answers = json.loads(payload.get("tool_response") or "").get("answers")
258
- except (ValueError, TypeError, AttributeError):
338
+ except (ValueError, TypeError, AttributeError, RecursionError):
259
339
  answers = None
260
340
  out = []
261
341
  for q in questions:
@@ -332,7 +412,7 @@ def write_line(root, data):
332
412
  os.close(fd)
333
413
 
334
414
 
335
- def read_ledger(root, path=None, types=LEDGER_TYPES):
415
+ def read_ledger(root, path=None, types=LEDGER_TYPES): # pylint: disable=dangerous-default-value # read-only: `types` is only tested with `in`, never mutated
336
416
  """Every well-formed line of the given types, in order; a torn or foreign
337
417
  line is skipped, so a damaged ledger shows fewer picks, never more."""
338
418
  entries = []
@@ -341,7 +421,7 @@ def read_ledger(root, path=None, types=LEDGER_TYPES):
341
421
  for raw in fh:
342
422
  try:
343
423
  entry = json.loads(raw)
344
- except ValueError:
424
+ except (ValueError, RecursionError):
345
425
  continue
346
426
  if isinstance(entry, dict) and entry.get("v") == 1 and entry.get("type") in types:
347
427
  entries.append(entry)
@@ -32,6 +32,10 @@ WHY_CONSENT = ("\n\nWith the dialog on, a commit or push runs only when the huma
32
32
  "send the dialog it prints, then run the picked command verbatim, once. See\n"
33
33
  ".ai/policies/safety.md.")
34
34
 
35
+ WHY_CONSENT_CLOSED = ("\n\nWith the dialog on, a commit or push runs only when the human picked it in the\n"
36
+ "dialog, and only a session that can answer the dialog can have a pick. See\n"
37
+ ".ai/policies/safety.md.")
38
+
35
39
  SPACE = "[[:space:]]"
36
40
  CHEAP_RE = ("(^|[|;&[:space:]])(git|gh|argocd|helm|kubectl|terraform|dep|deployer|cap|flyctl|fly|"
37
41
  "vercel|netlify|npm)([[:space:]]|$)|/npm([[:space:]]|$)")
@@ -257,14 +261,19 @@ class GitGuard:
257
261
  raw = values[0] if len(values) == 1 and isinstance(values[0], dict) else None
258
262
  hook_file = os.path.join(os.fsdecode(b_bytes(self.ctx.hook_dir)), "ai-guard.py")
259
263
  try:
260
- on = raw is not None and dialog.effective_switch(root, dialog.payload_runtime(raw, hook_file), raw)
264
+ state = dialog.switch_state(root, dialog.payload_runtime(raw, hook_file), raw)
261
265
  except Exception: # pylint: disable=broad-exception-caught
262
- on = False
263
- if not on:
266
+ state = "closed"
267
+ if state == "off":
264
268
  return
265
269
  command = b_bytes(cmd).decode("utf-8", "replace").strip()
266
270
  if not dialog.needs_consent(command):
267
271
  return
272
+ if state == "closed":
273
+ raise Deny(text_to_b("CONSENT_REQUIRED — this session cannot answer the dialog (a subagent, a "
274
+ "call with no session id, a session id other than the hook's own, or an "
275
+ "unreadable call). "
276
+ "The human runs this command in their own terminal.") + WHY_CONSENT_CLOSED)
268
277
  spent = getattr(self.ctx, "consent", None)
269
278
  if spent and spent.get("label") == command:
270
279
  return
@@ -154,15 +154,100 @@ LONG_TRIGGER_RE = ("(^|[^A-Za-z0-9_])approve([^A-Za-z0-9_]|$)|--by(=|[[:space:]]
154
154
  FETCHERS = "(^|[|;&[:space:]])(scp|tar|zip|unzip|curl|wget)([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+"
155
155
  # An interpreter one-liner is not parsed, its text is scanned: one that names a
156
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:]]|$)"
157
+ INTERP_RE = ("(^|[|;&[:space:]])(python3?|perl|ruby|node|php)([[:space:]]+-[^[:space:]]+)*[[:space:]]+-(c|e)([[:space:]]|$)|"
158
+ "(^|[|;&[:space:]])php([[:space:]]+-[^[:space:]]+)*[[:space:]]+-r([[:space:]]|$)")
158
159
  CONTROL_TEXT_RE = "\\.ai/state/|\\.ai/reports/"
159
- INPLACE_RE = ("(^|[|;&[:space:]])(perl|ruby)([[:space:]]+-([CEIMmr][[:space:]]*[^[:space:]]+|[^[:space:]]+))*"
160
- "[[:space:]]+-[acdfghlnpsStTuUvwWXy]*i")
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
+
161
246
  EVAL_WORD_RE = "(^|[|;&[:space:]])eval([[:space:]]|$)"
162
247
  SECRET_TEXT_RE = "\\.env|secrets?/|credentials?|id_rsa|\\.pem|\\.aws"
163
248
  WRITE_CALL_RE = (
164
249
  "(^|[^A-Za-z0-9_])(write|writelines|write_text|write_bytes|write_file|writeFile|writeFileSync|appendFile|"
165
- "appendFileSync|createWriteStream|binwrite|syswrite|fwrite|fputs|file_put_contents|dump|unlink|unlinkSync|"
250
+ "appendFileSync|createWriteStream|binwrite|syswrite|fwrite|fputs|fprintf|vfprintf|writeSync|writevSync|file_put_contents|dump|unlink|unlinkSync|"
166
251
  "remove|removedirs|rename|renames|renameSync|rmdir|rmdirSync|rm|rmSync|rmtree|mkdir|mkdirSync|makedirs|"
167
252
  "mkdtemp|truncate|truncateSync|ftruncate|symlink|symlinkSync|symlink_to|hardlink_to|link|linkSync|link_to|"
168
253
  "touch|chmod|chown|utime|utimes|copyFile|copyFileSync|cp|cpSync|move|delete|system|exec|execSync|execFile|"
@@ -1182,7 +1267,8 @@ class PathGuard: # pylint: disable=too-many-instance-
1182
1267
  p = self.p
1183
1268
  if p.tool not in ("Read", "Edit", "Write", "MultiEdit", "NotebookEdit", "Bash"):
1184
1269
  return
1185
- unattended = bool(env_b("AI_UNATTENDED"))
1270
+ # AI_UNATTENDED is set when non-empty and not 0, false or no: one rule (risk 0587)
1271
+ unattended = env_b("AI_UNATTENDED").strip().lower() not in ("", "0", "false", "no")
1186
1272
  if p.tool == "Bash" and not unattended:
1187
1273
  self.confirm_delete(p.bash_command())
1188
1274
  root = find_ai_root(p.cwd, self.ctx.pwd)
@@ -1227,6 +1313,22 @@ class PathGuard: # pylint: disable=too-many-instance-
1227
1313
  command = b_bytes(cmd).decode("utf-8", "replace").strip()
1228
1314
  return True, bool(pick and pick.get("label") == command)
1229
1315
 
1316
+ def inplace_edit(self, cmd):
1317
+ """perl or ruby -i as a command: INPLACE_RE on the line less the segments of a text tool."""
1318
+ m = self.m
1319
+ if not m.ere_match(INPLACE_RE, cmd):
1320
+ return False
1321
+ if "<<" in cmd: # a heredoc body is read by whoever reads it
1322
+ return True
1323
+ segs = shell_segments(cmd).split("\n")
1324
+ if "|" in cmd and any(shell_sink(sg) for sg in segs):
1325
+ return True # a text tool's output may run as shell text
1326
+ git_runs = any(t in cmd for t in _GIT_RUNS)
1327
+ if git_runs and any(_tool_word(sg.split(None, 1)[0]) == "git" for sg in segs if sg.strip()):
1328
+ return True # the assignment that runs a command is not in the segment
1329
+ keep = [sg for sg in segs if not text_tool_segment(sg, git_runs)]
1330
+ return m.ere_match(INPLACE_RE, "\n".join(keep))
1331
+
1230
1332
  def interpreter_text(self, cmd):
1231
1333
  """The text of an interpreter one-liner or an eval, scanned whole (H3)."""
1232
1334
  m = self.m
@@ -1235,10 +1337,10 @@ class PathGuard: # pylint: disable=too-many-instance-
1235
1337
  if (is_eval or is_interp) and m.ere_match(SECRET_TEXT_RE, cmd):
1236
1338
  raise Deny("Refusing an interpreter one-liner that references a sensitive path." + WHY_SENSITIVE)
1237
1339
  if m.ere_match(CONTROL_TEXT_RE, cmd):
1238
- if is_eval or m.ere_match(INPLACE_RE, cmd):
1340
+ if is_eval or self.inplace_edit(cmd):
1239
1341
  raise Deny("Refusing an interpreter one-liner that writes the task's own control files." + WHY_APPROVE)
1240
1342
  if is_interp:
1241
- scan = cmd.replace("stdout.write", " ").replace("stderr.write", " ")
1343
+ scan = _STD_WRITE.sub(" ", cmd.replace("stdout.write", " ").replace("stderr.write", " "))
1242
1344
  pscan = cmd.replace("copy.copy(", " ")
1243
1345
  if (m.ere_match(WRITE_CALL_RE, scan) or m.ere_match(WRITE_WORD_RE, cmd)
1244
1346
  or (m.ere_match("Path", cmd)
@@ -204,7 +204,8 @@ class Scaffold:
204
204
  if os.path.exists(os.path.join(d, ".gitkeep")):
205
205
  return
206
206
  os.makedirs(d, exist_ok=True)
207
- open(os.path.join(d, ".gitkeep"), "wb").close()
207
+ with open(os.path.join(d, ".gitkeep"), "wb"):
208
+ pass
208
209
  self.created.append(rel + "/.gitkeep")
209
210
 
210
211
  def append_once(self, path, marker, template, label):