riskrails 3.0.0 → 3.1.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 (112) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/README.md +259 -812
  5. package/agents/ai-reviewer.md.tmpl +6 -4
  6. package/ai/policies/model-routing.md +7 -7
  7. package/ai/policies/production.md +3 -3
  8. package/ai/policies/release.md +2 -2
  9. package/ai/policies/review-economy.md +34 -32
  10. package/ai/using-riskrails.md +15 -0
  11. package/bin/claude-1m +15 -14
  12. package/bin/riskrails.js +113 -37
  13. package/codex/hooks.json +25 -2
  14. package/hooks/AGENTS.md +2 -2
  15. package/hooks/CLAUDE.md +2 -2
  16. package/hooks/ai-guard.py +29 -8
  17. package/hooks/ai-path-guard-defaults.json +3 -1
  18. package/hooks/codex-hooks.json +25 -2
  19. package/hooks/context-guard.py +252 -36
  20. package/hooks/dialog-gate.py +105 -0
  21. package/hooks/guard/common.py +7 -0
  22. package/hooks/guard/decision.py +123 -0
  23. package/hooks/guard/dialog.py +598 -0
  24. package/hooks/guard/git.py +49 -3
  25. package/hooks/guard/paths.py +89 -19
  26. package/hooks/hooks.json +37 -2
  27. package/hooks/memory-pre-push.py +28 -0
  28. package/hooks/project-scaffold.py +1 -5
  29. package/hooks/runtime-gate.py +65 -18
  30. package/hooks/update_notice.py +141 -0
  31. package/instructions/routing.md +4 -4
  32. package/instructions/stub.md +3 -3
  33. package/package.json +1 -1
  34. package/profiles/max.json +3 -1
  35. package/profiles/pro.json +3 -1
  36. package/scripts/merge-codex-config.py +134 -0
  37. package/scripts/resolve-profile.py +15 -3
  38. package/settings.common.json +39 -2
  39. package/skills/AGENTS.md +3 -3
  40. package/skills/CLAUDE.md +3 -3
  41. package/skills/ai-init/templates/.ai/AGENTS.md +3 -3
  42. package/skills/ai-init/templates/.ai/VERSION +1 -1
  43. package/skills/ai-init/templates/.ai/policies/dialog.json +1 -0
  44. package/skills/ai-init/templates/.ai/policies/safety.md +22 -2
  45. package/skills/ai-init/templates/.ai/policies/security.md +7 -1
  46. package/skills/ai-init/templates/.ai/policies/testing.md +19 -6
  47. package/skills/ai-init/templates/.ai/policies/weight.json +1 -0
  48. package/skills/ai-init/templates/.ai/policies/weight.md +2 -2
  49. package/skills/ai-init/templates/.ai/state/README.md +9 -6
  50. package/skills/ai-init/templates/AGENTS.block.md +2 -2
  51. package/skills/ai-init/templates/CLAUDE.block.md +2 -2
  52. package/skills/ai-init/templates/GEMINI.block.md +2 -2
  53. package/skills/ai-init/templates/gitignore.snippet +1 -0
  54. package/skills/ai-init/templates/junie-guidelines.block.md +2 -2
  55. package/skills/ai-task/memory_compact.py +968 -0
  56. package/skills/ai-task/memory_format.py +775 -0
  57. package/skills/ai-task/memory_store.py +528 -0
  58. package/skills/ai-task/memory_sync.py +1571 -0
  59. package/skills/ai-task/sensors.py +25 -5
  60. package/skills/ai-task/state.py +999 -79
  61. package/skills/ai-task/weight.py +3 -3
  62. package/skills/project-init/templates/constitution.md +1 -1
  63. package/skills/project-init/templates/sdlc-README.md +20 -14
  64. package/skills/project-update/history/blobs/00289f98434fed230eebe768dfb3425f83d737866a3aebc97aa95167c4fa0c6e +131 -0
  65. package/skills/project-update/history/blobs/079a91f976ad69b5cf49ccb9a28849576e4735ddd9485fc26599e528a69fd2f4 +30 -0
  66. package/skills/project-update/history/blobs/07ff7fa6f93d471ff533f553b4e523e4105ccde1f3dab70132c5237728f68422 +13 -0
  67. package/skills/project-update/history/blobs/0ef4a360c76da290173dea0f8073949cb0f8113710a38df95acae5d96bdd1286 +123 -0
  68. package/skills/project-update/history/blobs/164cd34c182de88c33de7df35beb29200d792419e1c13a5f0b2104f944e57be7 +1 -0
  69. package/skills/project-update/history/blobs/18c7a2753ce9cb6f6d3d1e9d5d1125d75950b879d56b7325225b200869631b29 +15 -0
  70. package/skills/project-update/history/blobs/1d1d907449ec74594d0ee923ac142ae746b9aedf0a1eda9469f075a44d23e3d7 +13 -0
  71. package/skills/project-update/history/blobs/23a42febbacaa7b52a802d428033a99c0694391c81d51fe3b6e1dff608a641d5 +67 -0
  72. package/skills/project-update/history/blobs/25d4f2a86deb5e2574bb3210b67bb24fcc4afb19f93a7b65a057daa874a9d18e +1 -0
  73. package/skills/project-update/history/blobs/48d9950f16e31d55475f566a80dc95f64b35f1c804d2f51729e313a4f29bac8d +13 -0
  74. package/skills/project-update/history/blobs/5353ca529c46f2443e12947ec205a6887fb594a4de1f87f0eb1c0e6c0f826133 +49 -0
  75. package/skills/project-update/history/blobs/5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c +6 -0
  76. package/skills/project-update/history/blobs/5f65bc7ab3fc3c4e75ae05d49eaf4331fafc398c661abe33c69f7380a720344c +32 -0
  77. package/skills/project-update/history/blobs/6a3be0e624e5ad91e44d08857b7183ee53b7903e1e8d56ff196dbc9dde271e4a +75 -0
  78. package/skills/project-update/history/blobs/7170553410bba904ffb7edb4b231e3b3579b85542d84de717299980938f82c9d +13 -0
  79. package/skills/project-update/history/blobs/774b1e3a04ef0d49d33c44a2cfecd92eb6f6b738a12b6a681103ba7fc13e7b86 +67 -0
  80. package/skills/project-update/history/blobs/7be527cd648b27cca6e267de30f8e33a2ce17d5631a82694f23ae5c0623efe1f +15 -0
  81. package/skills/project-update/history/blobs/7ee99edafe7cec45ff577376c0d0178fdb2690134d4656488f46e129aba3cc5a +67 -0
  82. package/skills/project-update/history/blobs/7f063e79195a5c85c37b7260dd20c184d3b9bea85e37202e0bbca73ad84bba49 +13 -0
  83. package/skills/project-update/history/blobs/8b3ef9586491a426767c01da5d01bdd6b0655c576c68c4cc6dfb093367817dc0 +67 -0
  84. package/skills/project-update/history/blobs/917df3320d778ddbaa5c5c7742bc4046bf803c36ed2b050f30844ed206783469 +1 -0
  85. package/skills/project-update/history/blobs/935af1019931058ec3a0b834fe230e127ec914f75edcc4a6c589cfe733b0b979 +13 -0
  86. package/skills/project-update/history/blobs/ae5ce131d6bae401199ff32d8c3dc4d8aec188010a9111624ac64506e2116ff4 +84 -0
  87. package/skills/project-update/history/blobs/c8573ca1f810ce6c4ac49456a4c1725b93e0d3b74c54ce95d46d8747777895f0 +13 -0
  88. package/skills/project-update/history/blobs/ca4499d829268c8766fb3b825a515e88dc55c237e9d202816b50959b4dac031f +13 -0
  89. package/skills/project-update/history/blobs/d6e5b34d475e7187d2a8cb2aac7e7f1a427142baf6461bda387d70ceb4d8a8c1 +30 -0
  90. package/skills/project-update/history/blobs/e120ff4e32b2e950fc94ada4e787d57dc9c790da37223e5a202d74f7e3aadf80 +13 -0
  91. package/skills/project-update/history/blobs/e9adb7a9905eb719ac78ab96671f055da53e5001810f5ad6d6c94306b0323fe7 +74 -0
  92. package/skills/project-update/history/blobs/fae3064e648cdc55be4aeb1177525b302a5e34ebd752e4a904f47dc0f4557b96 +71 -0
  93. package/skills/project-update/history/index.json +60 -20
  94. package/skills/project-update/migrations/0009_state_file.py +25 -0
  95. package/skills/project-update/migrations/0010_dialog_switch.py +30 -0
  96. package/skills/project-update/migrations/0011_retire_risk_tiers.py +37 -0
  97. package/skills/project-update/migrations/README.md +2 -1
  98. package/skills/project-update/migrations/__init__.py +6 -1
  99. package/skills/project-update/render_instructions.py +1 -1
  100. package/skills/project-update/update.py +385 -89
  101. package/skills/rr-build/SKILL.md +14 -5
  102. package/skills/rr-build-all/SKILL.md +190 -0
  103. package/skills/rr-plan/SKILL.md +16 -4
  104. package/skills/rr-review/SKILL.md +24 -9
  105. package/skills/rr-setup/SKILL.md +15 -2
  106. package/skills/rr-setup/setup.py +7 -1
  107. package/skills/rr-ship/SKILL.md +16 -8
  108. package/skills/rr-spec/SKILL.md +4 -2
  109. package/skills/rr-status/SKILL.md +2 -2
  110. package/skills/usage-report/usage-report.py +45 -10
  111. package/skills/ai-init/templates/.ai/policies/risk-tiers.json +0 -628
  112. package/skills/ai-init/templates/.ai/policies/risk-tiers.md +0 -180
@@ -0,0 +1,598 @@
1
+ """R36: the hooks' side of the command-choice dialog — the effective switch,
2
+ the consent ledger (.ai/state/consent.jsonl) and its reader, and the one
3
+ adapter that maps both runtimes' dialog tools to one shape.
4
+
5
+ state.py keeps its own small reader of the same ledger (spec design item 2);
6
+ tests/test_dialog_contract.py feeds both the same fixtures, so they cannot
7
+ drift. Nothing here imports state.py: hooks run under `python3 -I -S`, stdlib
8
+ only.
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".
15
+ """
16
+ import contextlib
17
+ import json
18
+ import os
19
+ import re
20
+ import shlex
21
+ import time
22
+ from datetime import datetime, timedelta, timezone
23
+
24
+ LABEL_MAX = 400
25
+ WINDOW = (120, 30, 600) # window_seconds: default, lower and upper bound
26
+ STOP_BLOCKS_MAX = 2
27
+ LOCK_STALE_S = 2.0
28
+ LINE_MAX = 4096 # one write; O_APPEND keeps a line whole up to this
29
+
30
+ DIALOG_TOOL = {"claude": "AskUserQuestion", "codex": "request_user_input"}
31
+ LEDGER_TYPES = {"offer", "pick", "use", "void", "decline", "settle"}
32
+ STOP_BLOCK = "stop_block" # the Stop loop breaker's count; no reader of consent reads it
33
+ LABEL_FORBIDDEN = (";", "&", "|", "\n", "\r", "`", "$(", "<", ">")
34
+ # state.py's own patterns, ASCII only: a label the hook accepts state.py accepts.
35
+ INTERPRETER_RE = re.compile(r"^python3?$")
36
+ INTERPRETER_FLAG_RE = re.compile(r"^-[IS]+$")
37
+ PUSH_REF_RE = re.compile(r"^[A-Za-z0-9._/-]+$")
38
+ NPX_PACKAGE_RE = re.compile(r"^riskrails@[0-9A-Za-z.+-]+$")
39
+ TS = "%Y-%m-%dT%H:%M:%SZ"
40
+
41
+ # hooks/guard/dialog.py -> the plugin root; the Stop reminder names its
42
+ # state.py. A label's state.py is checked by form only: on the plugin route the
43
+ # hooks run from the plugin cache while the skills run <home>'s state.py, and
44
+ # that state.py checks a pick's path against itself (R36.10).
45
+ PLUGIN = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
46
+ STATE_PY = os.path.join(PLUGIN, "skills", "ai-task", "state.py")
47
+
48
+
49
+ def stamp(at):
50
+ return at.strftime(TS)
51
+
52
+
53
+ def parse_ts(text):
54
+ try:
55
+ return datetime.strptime(text, TS).replace(tzinfo=timezone.utc)
56
+ except (TypeError, ValueError):
57
+ return None
58
+
59
+
60
+ def canonical(obj):
61
+ """One spelling of a JSON value, for comparing what was offered with what came back."""
62
+ return json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
63
+
64
+
65
+ # -- the switch ---------------------------------------------------------------
66
+
67
+ def dialog_policy(root):
68
+ """.ai/policies/dialog.json when it says enabled: true, else None."""
69
+ try:
70
+ 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):
73
+ return None
74
+ if not isinstance(data, dict) or data.get("enabled") is not True:
75
+ return None
76
+
77
+ def bounded(key, default, low, high):
78
+ value = data.get(key, default)
79
+ value = value if isinstance(value, int) and not isinstance(value, bool) else default
80
+ return min(high, max(low, value))
81
+ return {"window_seconds": bounded("window_seconds", *WINDOW),
82
+ "stop_blocks_max": bounded("stop_blocks_max", STOP_BLOCKS_MAX, 0, STOP_BLOCKS_MAX)}
83
+
84
+
85
+ def runtime_home(runtime):
86
+ if runtime == "codex":
87
+ return os.environ.get("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex")
88
+ return os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(os.path.expanduser("~"), ".claude")
89
+
90
+
91
+ def dialog_tool(runtime):
92
+ """The runtime's dialog tool when the installer verified it (profile.json
93
+ dialog.tool, R36.19), else None: Codex stays off on an unverified version.
94
+ Only an installer from 7b on writes dialog.tool, always under riskrails/."""
95
+ try:
96
+ 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):
99
+ return None
100
+ return tool if runtime in DIALOG_TOOL and tool == DIALOG_TOOL[runtime] else None
101
+
102
+
103
+ def payload_runtime(payload, hook_file):
104
+ """Codex's envelope carries turn_id, Claude's prompt_id (docs/dialog-spike.md);
105
+ an envelope with neither is read from where the hook is installed."""
106
+ if isinstance(payload, dict):
107
+ if "turn_id" in payload or payload.get("tool_name") == DIALOG_TOOL["codex"]:
108
+ return "codex"
109
+ if "prompt_id" in payload or payload.get("tool_name") == DIALOG_TOOL["claude"]:
110
+ return "claude"
111
+ return "codex" if "%s.codex%s" % (os.sep, os.sep) in os.path.abspath(hook_file) else "claude"
112
+
113
+
114
+ 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"))
126
+
127
+
128
+ # -- labels -------------------------------------------------------------------
129
+
130
+ def validate_label(label):
131
+ """None when `label` may stand for a command (R36.4), else why not; the
132
+ rule of state.py's validate_label, checked again when a pick is recorded."""
133
+ if not isinstance(label, str) or not label or label != label.strip():
134
+ return "a label is a non-empty string without surrounding blanks"
135
+ if len(label) > LABEL_MAX or not label.isprintable():
136
+ return "a label is at most %d printable characters" % LABEL_MAX
137
+ if any(c in label for c in LABEL_FORBIDDEN):
138
+ return "a label is one simple command"
139
+ try:
140
+ tokens = shlex.split(label)
141
+ except ValueError:
142
+ return "a label must split as a shell command"
143
+ if tokens[:2] == ["git", "commit"]:
144
+ return None
145
+ if tokens[:2] == ["git", "push"]:
146
+ refs_ok = len(tokens) == 4 and all(PUSH_REF_RE.match(t) and not t.startswith("-") for t in tokens[2:])
147
+ return None if refs_ok else "a push label is exactly 'git push <remote> <branch>'"
148
+ if tokens[:1] == ["npx"] and len(tokens) >= 3 and NPX_PACKAGE_RE.match(tokens[1]) and tokens[-1] == "--yes":
149
+ return None
150
+ if state_label_ok(tokens):
151
+ return None
152
+ return "a label starts with git commit, git push, a human-only state.py command or npx riskrails@<v>"
153
+
154
+
155
+ def state_label_ok(tokens):
156
+ """python3 [-I|-S…] <…/state.py> approve|reject|profile set|verify --by|
157
+ review --by|start --mode … --by."""
158
+ if not tokens or not INTERPRETER_RE.match(tokens[0]):
159
+ return False
160
+ i = 1
161
+ while i < len(tokens) and INTERPRETER_FLAG_RE.match(tokens[i]):
162
+ i += 1
163
+ if i >= len(tokens) or os.path.basename(tokens[i]) != "state.py":
164
+ return False
165
+ rest = tokens[i + 1:]
166
+ while rest[:1] in (["--root"], ["--runtime"]) and len(rest) >= 2:
167
+ rest = rest[2:]
168
+ if rest[:1] in (["approve"], ["reject"]) or rest[:2] == ["profile", "set"]:
169
+ return True
170
+ if rest[:1] in (["verify"], ["review"]):
171
+ return "--by" in rest
172
+ return rest[:1] == ["start"] and "--mode" in rest and "--by" in rest
173
+
174
+
175
+ # -- decisions ----------------------------------------------------------------
176
+
177
+ def read_decisions(root):
178
+ """The open decisions of .ai/state/decision.json, in file order; [] when
179
+ there is none or it cannot be read (only state.py writes it)."""
180
+ try:
181
+ with open(os.path.join(root, ".ai", "state", "decision.json"), encoding="utf-8") as fh:
182
+ data = json.load(fh)
183
+ except (OSError, ValueError, TypeError):
184
+ return []
185
+ opened = data.get("open") if isinstance(data, dict) else None
186
+ return [d for d in opened if isinstance(d, dict) and d.get("id")] if isinstance(opened, list) else []
187
+
188
+
189
+ def decision_parts(decision):
190
+ """state.py's dialog_questions: a command list is one multi-select unless a
191
+ label holds ", " (a joined answer would be ambiguous); then one
192
+ single-select per command, the safe choice first. [(options, multi)]."""
193
+ options = [o for o in decision.get("options") or [] if isinstance(o, dict)]
194
+ commands = [o for o in options if o.get("kind") == "command"]
195
+ if decision.get("multi_select") and any(", " in str(o.get("label")) for o in commands):
196
+ safe = [o for o in options if o.get("kind") == "choice"][:1]
197
+ return [(safe + [o], False) for o in commands]
198
+ return [(options, bool(decision.get("multi_select")))]
199
+
200
+
201
+ def offered(decisions, runtime):
202
+ """What a dialog call for these decisions carries, question by question:
203
+ (decision, options, the question as sent) — state.py's dialog_payload."""
204
+ out = []
205
+ for decision in decisions:
206
+ parts = decision_parts(decision)
207
+ for i, (options, multi) in enumerate(parts, 1):
208
+ item = {"question": decision.get("question"), "header": decision.get("header"),
209
+ "options": [{"label": o.get("label"), "description": o.get("description") or (
210
+ "the session runs exactly this, once" if o.get("kind") == "command" else "nothing runs")}
211
+ for o in options]}
212
+ if runtime == "codex":
213
+ item = dict({"id": "%s%s" % (str(decision["id"]).lower(), "_%d" % i if len(parts) > 1 else "")},
214
+ **item)
215
+ else:
216
+ item["multiSelect"] = multi
217
+ out.append((decision, options, item))
218
+ return out
219
+
220
+
221
+ def question_key(question, runtime):
222
+ """What identifies a question across the call: its text, header, labels
223
+ with the descriptions the human read and, on Codex, its id. multiSelect is
224
+ the model's to echo."""
225
+ if not isinstance(question, dict):
226
+ return None
227
+ labels = [[o.get("label"), o.get("description")] if isinstance(o, dict) else None
228
+ for o in question.get("options") or []]
229
+ key = [question.get("question"), question.get("header"), labels]
230
+ return canonical(key + [question.get("id")] if runtime == "codex" else key)
231
+
232
+
233
+ # -- the adapter --------------------------------------------------------------
234
+
235
+ def response_questions(runtime, payload):
236
+ """The questions the dialog showed, in order."""
237
+ source = payload.get("tool_response") if runtime == "claude" else None
238
+ questions = source.get("questions") if isinstance(source, dict) else None
239
+ if not isinstance(questions, list):
240
+ tool_input = payload.get("tool_input")
241
+ questions = tool_input.get("questions") if isinstance(tool_input, dict) else None
242
+ return [q if isinstance(q, dict) else {} for q in questions] if isinstance(questions, list) else []
243
+
244
+
245
+ def adapt_response(runtime, payload):
246
+ """The human's answers, one list of items per question shown, from
247
+ tool_response — the harness's record, never the model's text (R36.6).
248
+
249
+ Claude: answers keyed by question text, a multi-select's labels joined
250
+ with ", "; two questions with one text cannot be told apart, so both read
251
+ as unanswerable. Codex: tool_response is a JSON string,
252
+ answers[<id>].answers is a list; "None of the above" and "user_note: …"
253
+ are items like any other, and match no label."""
254
+ questions = response_questions(runtime, payload)
255
+ if runtime == "codex":
256
+ try:
257
+ answers = json.loads(payload.get("tool_response") or "").get("answers")
258
+ except (ValueError, TypeError, AttributeError):
259
+ answers = None
260
+ out = []
261
+ for q in questions:
262
+ entry = answers.get(q.get("id")) if isinstance(answers, dict) else None
263
+ items = entry.get("answers") if isinstance(entry, dict) else None
264
+ out.append([i for i in items if isinstance(i, str)] if isinstance(items, list) else [])
265
+ return out
266
+ response = payload.get("tool_response")
267
+ answers = response.get("answers") if isinstance(response, dict) else None
268
+ texts = [q.get("question") for q in questions]
269
+ out = []
270
+ for q in questions:
271
+ answer = answers.get(q.get("question")) if isinstance(answers, dict) else None
272
+ if not isinstance(answer, str) or not answer or texts.count(q.get("question")) > 1:
273
+ out.append([""] if texts.count(q.get("question")) > 1 else [])
274
+ else:
275
+ out.append(answer.split(", ") if q.get("multiSelect") else [answer])
276
+ return out
277
+
278
+
279
+ # -- the ledger ---------------------------------------------------------------
280
+
281
+ def ledger_path(root):
282
+ return os.path.join(root, ".ai", "state", "consent.jsonl")
283
+
284
+
285
+ @contextlib.contextmanager
286
+ def ledger_lock(root):
287
+ """consent.lock by O_EXCL, portable where flock is not; a lock older than
288
+ LOCK_STALE_S belongs to a writer that died and is taken over (R36.6)."""
289
+ path = os.path.join(root, ".ai", "state", "consent.lock")
290
+ os.makedirs(os.path.dirname(path), exist_ok=True)
291
+ deadline = time.time() + 2 * LOCK_STALE_S
292
+ while True:
293
+ try:
294
+ os.close(os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600))
295
+ break
296
+ except FileExistsError:
297
+ with contextlib.suppress(OSError):
298
+ if time.time() - os.stat(path).st_mtime > LOCK_STALE_S:
299
+ os.remove(path)
300
+ continue
301
+ if time.time() > deadline:
302
+ raise OSError("consent.lock is held and cannot be taken over") from None
303
+ time.sleep(0.02)
304
+ try:
305
+ yield
306
+ finally:
307
+ with contextlib.suppress(OSError):
308
+ os.remove(path)
309
+
310
+
311
+ def ledger_append(root, line):
312
+ """One line, one write, O_APPEND; called under ledger_lock. A line over
313
+ LINE_MAX bytes is refused whole (OSError) rather than torn."""
314
+ write_line(root, encode_line(line))
315
+
316
+
317
+ def encode_line(line):
318
+ line = dict({"v": 1}, **line)
319
+ line.setdefault("at", stamp(datetime.now(timezone.utc)))
320
+ data = (json.dumps(line, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
321
+ if len(data) > LINE_MAX:
322
+ raise OSError("a consent line is over %d bytes" % LINE_MAX)
323
+ return data
324
+
325
+
326
+ def write_line(root, data):
327
+ fd = os.open(ledger_path(root), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o644)
328
+ try:
329
+ if os.write(fd, data) != len(data):
330
+ raise OSError("short write to consent.jsonl")
331
+ finally:
332
+ os.close(fd)
333
+
334
+
335
+ def read_ledger(root, path=None, types=LEDGER_TYPES):
336
+ """Every well-formed line of the given types, in order; a torn or foreign
337
+ line is skipped, so a damaged ledger shows fewer picks, never more."""
338
+ entries = []
339
+ try:
340
+ with open(path or ledger_path(root), encoding="utf-8") as fh:
341
+ for raw in fh:
342
+ try:
343
+ entry = json.loads(raw)
344
+ except ValueError:
345
+ continue
346
+ if isinstance(entry, dict) and entry.get("v") == 1 and entry.get("type") in types:
347
+ entries.append(entry)
348
+ except OSError:
349
+ return []
350
+ return entries
351
+
352
+
353
+ def ledger_index(ledger):
354
+ """(used {id: at}, voided ids, settled decisions, {decision: (call, picks of
355
+ its latest dialog call)}) — what live_picks and pending read."""
356
+ used, voided, settled, rounds = {}, set(), set(), {}
357
+ for entry in ledger:
358
+ kind = entry.get("type")
359
+ if kind == "use":
360
+ used[entry.get("id")] = parse_ts(entry.get("at"))
361
+ elif kind == "void":
362
+ voided.update(entry.get("ids") or [])
363
+ elif kind == "settle":
364
+ settled.add(entry.get("decision"))
365
+ elif kind == "pick" and isinstance(entry.get("order"), int) and not isinstance(entry.get("order"), bool):
366
+ call, chain = rounds.get(entry.get("decision"), (None, []))
367
+ if chain and call == entry.get("tool_use_id"):
368
+ chain.append(entry)
369
+ else:
370
+ rounds[entry.get("decision")] = (entry.get("tool_use_id"), [entry])
371
+ return used, voided, settled, rounds
372
+
373
+
374
+ def live_picks(ledger, session, at):
375
+ """The picks a command may consume at `at` (R36.7): of each decision's
376
+ latest dialog call (one tool_use_id), unused, not voided, the decision not
377
+ settled, same session, inside the window — and only the first unused one
378
+ in its order. A later pick's window restarts when the one before is used."""
379
+ used, voided, settled, rounds = ledger_index(ledger)
380
+ live = []
381
+ for decision, (_, chain) in rounds.items():
382
+ if decision in settled:
383
+ continue
384
+ previous_use = None
385
+ for pick in sorted(chain, key=lambda p: p["order"]):
386
+ if pick.get("id") in used:
387
+ previous_use = used[pick.get("id")]
388
+ continue
389
+ picked, expires = parse_ts(pick.get("at")), parse_ts(pick.get("expires_at"))
390
+ if pick.get("id") in voided or pick.get("session") != session or not picked or not expires:
391
+ break
392
+ if previous_use and previous_use > picked:
393
+ expires = previous_use + (expires - picked)
394
+ if at <= expires:
395
+ live.append(pick)
396
+ break
397
+ return live
398
+
399
+
400
+ def bound(decisions, runtime, session):
401
+ """The decisions of this runtime and session: the one that opened a
402
+ decision answers it (state.py records both; a decision with neither is
403
+ everyone's, as before 7d2)."""
404
+ return [d for d in decisions if d.get("runtime") in (None, "", runtime) and d.get("session") in (None, "", session)]
405
+
406
+
407
+ def pending(decisions, ledger):
408
+ """The decisions still waiting (state.py's prune_decisions): not settled,
409
+ and not every command pick of their latest dialog call used."""
410
+ used, _, settled, rounds = ledger_index(ledger)
411
+ out = []
412
+ for decision in decisions:
413
+ commands = {o.get("n") for o in decision.get("options") or [] if isinstance(o, dict) and o.get("kind") == "command"}
414
+ picks = [p for p in rounds.get(decision.get("id"), (None, []))[1] if p.get("option") in commands]
415
+ if decision.get("id") in settled or (picks and all(p.get("id") in used for p in picks)):
416
+ continue
417
+ out.append(decision)
418
+ return out
419
+
420
+
421
+ def unused_picks(ledger, decision_ids, session):
422
+ """The ids of this session's picks of the latest dialog call not yet used
423
+ or voided, for every decision named: what a mismatch voids (R36.8)."""
424
+ used, voided, _, rounds = ledger_index(ledger)
425
+ return [p.get("id") for d in decision_ids for p in rounds.get(d, (None, []))[1]
426
+ if p.get("session") == session and p.get("id") not in used and p.get("id") not in voided]
427
+
428
+
429
+ def unanswered_offer(ledger, session, tool_use_id, decision_ids):
430
+ """This session's last offer when it named an open decision and no pick or
431
+ decline of its call followed, seen from a later tool call: the recorder
432
+ did not run (R36.15)."""
433
+ offers = [e for e in ledger if e.get("type") == "offer" and e.get("session") == session]
434
+ if not offers or not offers[-1].get("tool_use_id") or offers[-1].get("tool_use_id") == tool_use_id:
435
+ return None
436
+ call = offers[-1]["tool_use_id"]
437
+ if any(e.get("type") in ("pick", "decline") and e.get("tool_use_id") == call for e in ledger):
438
+ return None
439
+ names = [d for d in offers[-1].get("decisions") or [] if d in decision_ids]
440
+ return dict(offers[-1], decisions=names) if names else None
441
+
442
+
443
+ # git in command position — after a separator, NAME=value words and exec-like
444
+ # prefixes, with its global options — running commit or push as its subcommand.
445
+ # `git stash push`, `git grep commit`, `echo git commit` are not that.
446
+ GIT_CONSENT_RE = re.compile(
447
+ r"(?:^|[|;&(\n]|\$\()\s*(?:(?:[A-Za-z_][A-Za-z0-9_]*=\S*|env|command|exec|nohup|time|sudo)\s+)*"
448
+ r"git(?:\s+(?:-[Cc]\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+(commit|push)(?=$|[\s|;&)])([^|;&\n)]*)")
449
+ HUMAN_VERB_RE = re.compile(r"(^|\s)(approve|reject)(\s|$)|(^|\s)profile\s+set(\s|$)|"
450
+ r"(^|\s)(verify|review|start)(\s.*)?\s--by(\s|=|$)")
451
+ DECISION_VERBS = ("show", "settle", "fallback")
452
+
453
+
454
+ def needs_consent(command):
455
+ """A git commit or push that would change something: what the consent rule
456
+ (R36.9) asks a pick for. Help and a push's dry run change nothing."""
457
+ for match in GIT_CONSENT_RE.finditer(command):
458
+ rest = match.group(2).split()
459
+ if "--help" in rest or "-h" in rest:
460
+ continue
461
+ if match.group(1) == "push" and ("--dry-run" in rest or "-n" in rest):
462
+ continue
463
+ return True
464
+ return False
465
+
466
+
467
+ def guarded(command):
468
+ """git commit/push or a human-only state.py command: what a mismatch with
469
+ the next live pick voids the picks for (R36.8)."""
470
+ return bool(needs_consent(command)
471
+ or (("state.py" in command or "STATE" in command) and HUMAN_VERB_RE.search(command)))
472
+
473
+
474
+ def decision_command(command):
475
+ """`[python3 [-I…]] <…/state.py | $STATE> [--root X] decision show|settle|
476
+ fallback …` as one simple command: the third thing the gate lets through."""
477
+ if any(c in command for c in LABEL_FORBIDDEN):
478
+ return False
479
+ try:
480
+ tokens = shlex.split(command)
481
+ except ValueError:
482
+ return False
483
+ i = 0
484
+ if tokens and INTERPRETER_RE.match(tokens[0]):
485
+ i = 1
486
+ while i < len(tokens) and INTERPRETER_FLAG_RE.match(tokens[i]):
487
+ i += 1
488
+ if i >= len(tokens) or not (os.path.basename(tokens[i]) == "state.py" or tokens[i] in ("$STATE", "${STATE}")):
489
+ return False
490
+ rest = tokens[i + 1:]
491
+ while rest and ((rest[0] in ("--root", "--runtime") and len(rest) >= 2) or rest[0].startswith("--root=")):
492
+ rest = rest[1:] if rest[0].startswith("--root=") else rest[2:]
493
+ return len(rest) >= 2 and rest[0] == "decision" and rest[1] in DECISION_VERBS
494
+
495
+
496
+ def is_git_label(label):
497
+ return (label or "").split()[:1] == ["git"]
498
+
499
+
500
+ def used_note(pick):
501
+ """What a deny after the gate spent a git pick adds (review focus 1)."""
502
+ return ("\n\nThe pick %s was used by this call and is spent; the command did not run. To ask the human "
503
+ "again, open a new decision: state.py decision open --kind commit --command \"<command>\"."
504
+ % pick.get("id"))
505
+
506
+
507
+ def next_live_pick(root, session_id, now):
508
+ """The one pick a command may match now, or None: the live pick picked
509
+ first (R36.7, 'only the next live pick in order')."""
510
+ live = live_picks(read_ledger(root), session_id, now) if session_id else []
511
+ if not live:
512
+ return None
513
+ return min(live, key=lambda p: (p.get("at") or "", p["order"]))
514
+
515
+
516
+ # -- the recorder (PostToolUse on the dialog tool) ----------------------------
517
+
518
+ def record(root, runtime, payload, now=None):
519
+ """Turn one dialog call's answers into ledger lines (R36.6) and return them.
520
+
521
+ Only questions equal to an open decision's count; a free dialog consents to
522
+ nothing. Per decision: every answer item must be an offered label, else a
523
+ `decline` (no answer, other text, an ambiguous join, a label that fails the
524
+ rule) whose text is not stored. Commands picked become one `pick` each, in
525
+ offered order; a decision answered only with a choice gets one `pick` of it.
526
+ The caller has checked effective_switch()."""
527
+ now = (now or datetime.now(timezone.utc)).replace(microsecond=0)
528
+ policy = dialog_policy(root)
529
+ decisions = read_decisions(root)
530
+ if policy is None or not decisions or payload.get("tool_name") != DIALOG_TOOL.get(runtime):
531
+ return []
532
+ # A runtime's payload always carries both (7.0): without the call's id the
533
+ # round cannot be told from another, and without the transcript `consent
534
+ # audit` cannot check it.
535
+ if not payload.get("tool_use_id") or not payload.get("transcript_path"):
536
+ return []
537
+ by_key = {question_key(item, runtime): (decision, options) for decision, options, item in
538
+ offered(decisions, runtime)}
539
+ answered = {} # decision id -> [decision, [(options, items)]]
540
+ for question, items in zip(response_questions(runtime, payload), adapt_response(runtime, payload)):
541
+ hit = by_key.get(question_key(question, runtime))
542
+ if hit:
543
+ answered.setdefault(hit[0]["id"], [hit[0], []])[1].append((hit[1], items))
544
+ if not answered:
545
+ return []
546
+ common = {"session": payload.get("session_id"), "tool_use_id": payload["tool_use_id"]}
547
+ lines = [dict({"type": "offer", "decisions": sorted(answered)}, **common)]
548
+ for decision, parts in answered.values():
549
+ picked, why = choose(decision, parts)
550
+ if why:
551
+ lines.append(dict({"type": "decline", "decision": decision["id"], "why": why}, **common))
552
+ continue
553
+ for order, option in enumerate(picked, 1):
554
+ lines.append(dict({"type": "pick", "decision": decision["id"], "option": option.get("n"),
555
+ "order": order, "label": option.get("label")}, **common,
556
+ transcript=payload["transcript_path"], runtime=runtime,
557
+ at=stamp(now), expires_at=stamp(now + timedelta(seconds=policy["window_seconds"]))))
558
+ written = []
559
+ with ledger_lock(root):
560
+ next_id = 1 + max([int(e["id"][1:]) for e in read_ledger(root) if e.get("type") == "pick" and
561
+ isinstance(e.get("id"), str) and e["id"][1:].isdigit()] or [0])
562
+ for line in lines:
563
+ if line["type"] == "pick":
564
+ line = dict({"id": "C%d" % next_id}, **line)
565
+ next_id += 1
566
+ written.append(dict(line, at=line.get("at") or stamp(now)))
567
+ # The round lands whole or not at all: every line is sized before the first write.
568
+ for data in [encode_line(line) for line in written]:
569
+ write_line(root, data)
570
+ return [dict({"v": 1}, **line) for line in written]
571
+
572
+
573
+ def choose(decision, parts):
574
+ """(options picked, None) or (None, why it is a decline)."""
575
+ commands, choices = [], []
576
+ for options, items in parts:
577
+ if not items or items == [""]:
578
+ return None, "ambiguous" if items == [""] else "no answer"
579
+ labels = {o.get("label"): o for o in options}
580
+ if any(item not in labels for item in items) or len(set(items)) != len(items):
581
+ return None, "other text"
582
+ for item in items:
583
+ (commands if labels[item].get("kind") == "command" else choices).append(labels[item])
584
+ split = len(decision_parts(decision)) > 1
585
+ if commands and choices and not split:
586
+ return None, "ambiguous"
587
+ if commands:
588
+ if any(validate_label(o.get("label")) for o in commands):
589
+ return None, "label"
590
+ return sorted({id(o): o for o in commands}.values(), key=lambda o: o.get("n") or 0), None
591
+ return choices[:1], None
592
+
593
+
594
+ # -- Stop ---------------------------------------------------------------------
595
+
596
+ def dialog_payload(decisions, runtime):
597
+ """The JSON the agent sends to the dialog tool, as state.py prints it."""
598
+ return {"questions": [item for _, _, item in offered(decisions, runtime)][:4]}
@@ -5,11 +5,13 @@ protected branch, staging a secret, bypassing hooks and running a production
5
5
  deploy, in any repository. Rules match the command with prose removed; the
6
6
  secret-staging rule reads the command itself, because a path is an argument.
7
7
  """
8
+ import os
8
9
  import re
10
+ from datetime import datetime, timezone
9
11
 
10
- from guard.common import (Deny, b_bytes, bytes_b, chomp_all, herestring_lines,
11
- is_file, is_jq_string, load_json_file, read_lines,
12
- runtime_hook_config, strip1, strip_prose, text_to_b)
12
+ from guard.common import (Deny, b_bytes, bytes_b, chomp_all, exists, find_ai_root,
13
+ herestring_lines, is_file, is_jq_string, load_json_file,
14
+ read_lines, runtime_hook_config, strip1, strip_prose, text_to_b)
13
15
 
14
16
  LIST_KEYS = ("protected_branches", "deploy_patterns", "sensitive_staging_patterns",
15
17
  "allow_force_push_repos", "allow_protected_push_repos")
@@ -25,6 +27,10 @@ WHY_PUBLISH = ("\n\nA published version is public at once and cannot be taken ba
25
27
  WHY_DEPLOY = ("\n\nProduction deployments are a human action with a human on the hook for the\n"
26
28
  "rollback. The pipeline ends at the release report and human approval; see\n"
27
29
  "policies/release.md.")
30
+ WHY_CONSENT = ("\n\nWith the dialog on, a commit or push runs only when the human picked it in the\n"
31
+ "dialog. Open a decision with 'state.py decision open --kind commit --command \"<command>\"',\n"
32
+ "send the dialog it prints, then run the picked command verbatim, once. See\n"
33
+ ".ai/policies/safety.md.")
28
34
 
29
35
  SPACE = "[[:space:]]"
30
36
  CHEAP_RE = ("(^|[|;&[:space:]])(git|gh|argocd|helm|kubectl|terraform|dep|deployer|cap|flyctl|fly|"
@@ -232,6 +238,46 @@ class GitGuard:
232
238
  raise Deny("Refusing to run a deployment or infrastructure-mutating command.\n\n"
233
239
  "Matched pattern: %s%s" % (pattern, WHY_DEPLOY))
234
240
 
241
+ # 9. consent (R36.9): with the dialog on, after every rule above — a
242
+ # pick never lifts one of them.
243
+ if m.ere_match(COMMIT_PUSH_RE, cmd_rules):
244
+ self.consent(cmd)
245
+
246
+ def consent(self, cmd):
247
+ """A commit or push runs only as the next live pick of this session,
248
+ verbatim; its `use` is appended here unless the decision gate spent it
249
+ on this call already. This one rule fails closed: a ledger that cannot
250
+ be read or locked is no consent."""
251
+ root = find_ai_root(self.cwd, self.ctx.pwd)
252
+ if root is None or not exists(root + "/.ai/policies/dialog.json"):
253
+ return
254
+ from guard import dialog # pylint: disable=import-outside-toplevel
255
+ root = os.fsdecode(b_bytes(root))
256
+ values = self.ctx.payload.values
257
+ raw = values[0] if len(values) == 1 and isinstance(values[0], dict) else None
258
+ hook_file = os.path.join(os.fsdecode(b_bytes(self.ctx.hook_dir)), "ai-guard.py")
259
+ try:
260
+ on = raw is not None and dialog.effective_switch(root, dialog.payload_runtime(raw, hook_file), raw)
261
+ except Exception: # pylint: disable=broad-exception-caught
262
+ on = False
263
+ if not on:
264
+ return
265
+ command = b_bytes(cmd).decode("utf-8", "replace").strip()
266
+ if not dialog.needs_consent(command):
267
+ return
268
+ spent = getattr(self.ctx, "consent", None)
269
+ if spent and spent.get("label") == command:
270
+ return
271
+ try:
272
+ with dialog.ledger_lock(root):
273
+ pick = dialog.next_live_pick(root, raw.get("session_id"), datetime.now(timezone.utc))
274
+ if pick and pick.get("label") == command and dialog.is_git_label(command):
275
+ dialog.ledger_append(root, {"type": "use", "id": pick["id"], "by": "git"})
276
+ return
277
+ except Exception: # pylint: disable=broad-exception-caught
278
+ pass
279
+ raise Deny(text_to_b("CONSENT_REQUIRED \u2014 no live pick of this session is this command.") + WHY_CONSENT)
280
+
235
281
  def _drop_status(self, path):
236
282
  """`[ "${#path}" -lt 3 ] || path="${path:3}"`, counted in the locale's characters."""
237
283
  loc = self.ctx.loc