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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +12 -11
- package/bin/riskrails.js +109 -18
- package/hooks/ai-path-guard-defaults.json +3 -2
- package/hooks/context-guard.py +14 -11
- package/hooks/dialog-gate.py +1 -1
- package/hooks/guard/dialog.py +107 -27
- package/hooks/guard/git.py +12 -3
- package/hooks/guard/paths.py +109 -7
- package/hooks/project-scaffold.py +2 -1
- package/hooks/runtime-gate.py +128 -27
- package/hooks/update_notice.py +3 -3
- package/package.json +1 -1
- package/skills/ai-init/templates/.ai/state/README.md +4 -4
- package/skills/ai-task/rr_state/__init__.py +1 -0
- package/skills/ai-task/rr_state/cli.py +210 -0
- package/skills/ai-task/rr_state/close.py +171 -0
- package/skills/ai-task/rr_state/dialog.py +541 -0
- package/skills/ai-task/rr_state/dialog_cmd.py +164 -0
- package/skills/ai-task/rr_state/fs.py +220 -0
- package/skills/ai-task/rr_state/gate.py +125 -0
- package/skills/ai-task/rr_state/gate_cmd.py +128 -0
- package/skills/ai-task/rr_state/handoff.py +226 -0
- package/skills/ai-task/rr_state/journal.py +126 -0
- package/skills/ai-task/rr_state/labels.py +99 -0
- package/skills/ai-task/rr_state/ledger.py +145 -0
- package/skills/ai-task/rr_state/locks.py +84 -0
- package/skills/ai-task/rr_state/memory_cmd.py +310 -0
- package/skills/ai-task/rr_state/policy.py +378 -0
- package/skills/ai-task/rr_state/profile.py +373 -0
- package/skills/ai-task/rr_state/questions.py +336 -0
- package/skills/ai-task/rr_state/questions_cmd.py +321 -0
- package/skills/ai-task/rr_state/report.py +271 -0
- package/skills/ai-task/rr_state/report_cmd.py +230 -0
- package/skills/ai-task/rr_state/review.py +165 -0
- package/skills/ai-task/rr_state/runtime.py +80 -0
- package/skills/ai-task/rr_state/start.py +257 -0
- package/skills/ai-task/rr_state/steps.py +131 -0
- package/skills/ai-task/rr_state/task.py +396 -0
- package/skills/ai-task/rr_state/verify.py +231 -0
- package/skills/ai-task/sensors.py +16 -3
- package/skills/ai-task/state.py +24 -5161
- package/skills/project-update/adopt.py +38 -24
- package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
- package/skills/project-update/history/index.json +2 -1
- package/skills/project-update/migrations/0003_context_diet.py +1 -1
- package/skills/project-update/migrations/0007_weight_model.py +130 -23
- package/skills/project-update/migrations/README.md +16 -1
- package/skills/project-update/update.py +202 -26
- package/skills/rr-setup/SKILL.md +9 -2
- package/skills/rr-status/status.py +36 -9
package/hooks/guard/dialog.py
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 =
|
|
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 = (
|
|
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
|
-
"""
|
|
116
|
-
|
|
117
|
-
(
|
|
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"),
|
|
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)
|
package/hooks/guard/git.py
CHANGED
|
@@ -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
|
-
|
|
264
|
+
state = dialog.switch_state(root, dialog.payload_runtime(raw, hook_file), raw)
|
|
261
265
|
except Exception: # pylint: disable=broad-exception-caught
|
|
262
|
-
|
|
263
|
-
if
|
|
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
|
package/hooks/guard/paths.py
CHANGED
|
@@ -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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
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")
|
|
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):
|