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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/README.md +259 -812
- package/agents/ai-reviewer.md.tmpl +6 -4
- package/ai/policies/model-routing.md +7 -7
- package/ai/policies/production.md +3 -3
- package/ai/policies/release.md +2 -2
- package/ai/policies/review-economy.md +34 -32
- package/ai/using-riskrails.md +15 -0
- package/bin/claude-1m +15 -14
- package/bin/riskrails.js +113 -37
- package/codex/hooks.json +25 -2
- package/hooks/AGENTS.md +2 -2
- package/hooks/CLAUDE.md +2 -2
- package/hooks/ai-guard.py +29 -8
- package/hooks/ai-path-guard-defaults.json +3 -1
- package/hooks/codex-hooks.json +25 -2
- package/hooks/context-guard.py +252 -36
- package/hooks/dialog-gate.py +105 -0
- package/hooks/guard/common.py +7 -0
- package/hooks/guard/decision.py +123 -0
- package/hooks/guard/dialog.py +598 -0
- package/hooks/guard/git.py +49 -3
- package/hooks/guard/paths.py +89 -19
- package/hooks/hooks.json +37 -2
- package/hooks/memory-pre-push.py +28 -0
- package/hooks/project-scaffold.py +1 -5
- package/hooks/runtime-gate.py +65 -18
- package/hooks/update_notice.py +141 -0
- package/instructions/routing.md +4 -4
- package/instructions/stub.md +3 -3
- package/package.json +1 -1
- package/profiles/max.json +3 -1
- package/profiles/pro.json +3 -1
- package/scripts/merge-codex-config.py +134 -0
- package/scripts/resolve-profile.py +15 -3
- package/settings.common.json +39 -2
- package/skills/AGENTS.md +3 -3
- package/skills/CLAUDE.md +3 -3
- package/skills/ai-init/templates/.ai/AGENTS.md +3 -3
- package/skills/ai-init/templates/.ai/VERSION +1 -1
- package/skills/ai-init/templates/.ai/policies/dialog.json +1 -0
- package/skills/ai-init/templates/.ai/policies/safety.md +22 -2
- package/skills/ai-init/templates/.ai/policies/security.md +7 -1
- package/skills/ai-init/templates/.ai/policies/testing.md +19 -6
- package/skills/ai-init/templates/.ai/policies/weight.json +1 -0
- package/skills/ai-init/templates/.ai/policies/weight.md +2 -2
- package/skills/ai-init/templates/.ai/state/README.md +9 -6
- package/skills/ai-init/templates/AGENTS.block.md +2 -2
- package/skills/ai-init/templates/CLAUDE.block.md +2 -2
- package/skills/ai-init/templates/GEMINI.block.md +2 -2
- package/skills/ai-init/templates/gitignore.snippet +1 -0
- package/skills/ai-init/templates/junie-guidelines.block.md +2 -2
- package/skills/ai-task/memory_compact.py +968 -0
- package/skills/ai-task/memory_format.py +775 -0
- package/skills/ai-task/memory_store.py +528 -0
- package/skills/ai-task/memory_sync.py +1571 -0
- package/skills/ai-task/sensors.py +25 -5
- package/skills/ai-task/state.py +999 -79
- package/skills/ai-task/weight.py +3 -3
- package/skills/project-init/templates/constitution.md +1 -1
- package/skills/project-init/templates/sdlc-README.md +20 -14
- package/skills/project-update/history/blobs/00289f98434fed230eebe768dfb3425f83d737866a3aebc97aa95167c4fa0c6e +131 -0
- package/skills/project-update/history/blobs/079a91f976ad69b5cf49ccb9a28849576e4735ddd9485fc26599e528a69fd2f4 +30 -0
- package/skills/project-update/history/blobs/07ff7fa6f93d471ff533f553b4e523e4105ccde1f3dab70132c5237728f68422 +13 -0
- package/skills/project-update/history/blobs/0ef4a360c76da290173dea0f8073949cb0f8113710a38df95acae5d96bdd1286 +123 -0
- package/skills/project-update/history/blobs/164cd34c182de88c33de7df35beb29200d792419e1c13a5f0b2104f944e57be7 +1 -0
- package/skills/project-update/history/blobs/18c7a2753ce9cb6f6d3d1e9d5d1125d75950b879d56b7325225b200869631b29 +15 -0
- package/skills/project-update/history/blobs/1d1d907449ec74594d0ee923ac142ae746b9aedf0a1eda9469f075a44d23e3d7 +13 -0
- package/skills/project-update/history/blobs/23a42febbacaa7b52a802d428033a99c0694391c81d51fe3b6e1dff608a641d5 +67 -0
- package/skills/project-update/history/blobs/25d4f2a86deb5e2574bb3210b67bb24fcc4afb19f93a7b65a057daa874a9d18e +1 -0
- package/skills/project-update/history/blobs/48d9950f16e31d55475f566a80dc95f64b35f1c804d2f51729e313a4f29bac8d +13 -0
- package/skills/project-update/history/blobs/5353ca529c46f2443e12947ec205a6887fb594a4de1f87f0eb1c0e6c0f826133 +49 -0
- package/skills/project-update/history/blobs/5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c +6 -0
- package/skills/project-update/history/blobs/5f65bc7ab3fc3c4e75ae05d49eaf4331fafc398c661abe33c69f7380a720344c +32 -0
- package/skills/project-update/history/blobs/6a3be0e624e5ad91e44d08857b7183ee53b7903e1e8d56ff196dbc9dde271e4a +75 -0
- package/skills/project-update/history/blobs/7170553410bba904ffb7edb4b231e3b3579b85542d84de717299980938f82c9d +13 -0
- package/skills/project-update/history/blobs/774b1e3a04ef0d49d33c44a2cfecd92eb6f6b738a12b6a681103ba7fc13e7b86 +67 -0
- package/skills/project-update/history/blobs/7be527cd648b27cca6e267de30f8e33a2ce17d5631a82694f23ae5c0623efe1f +15 -0
- package/skills/project-update/history/blobs/7ee99edafe7cec45ff577376c0d0178fdb2690134d4656488f46e129aba3cc5a +67 -0
- package/skills/project-update/history/blobs/7f063e79195a5c85c37b7260dd20c184d3b9bea85e37202e0bbca73ad84bba49 +13 -0
- package/skills/project-update/history/blobs/8b3ef9586491a426767c01da5d01bdd6b0655c576c68c4cc6dfb093367817dc0 +67 -0
- package/skills/project-update/history/blobs/917df3320d778ddbaa5c5c7742bc4046bf803c36ed2b050f30844ed206783469 +1 -0
- package/skills/project-update/history/blobs/935af1019931058ec3a0b834fe230e127ec914f75edcc4a6c589cfe733b0b979 +13 -0
- package/skills/project-update/history/blobs/ae5ce131d6bae401199ff32d8c3dc4d8aec188010a9111624ac64506e2116ff4 +84 -0
- package/skills/project-update/history/blobs/c8573ca1f810ce6c4ac49456a4c1725b93e0d3b74c54ce95d46d8747777895f0 +13 -0
- package/skills/project-update/history/blobs/ca4499d829268c8766fb3b825a515e88dc55c237e9d202816b50959b4dac031f +13 -0
- package/skills/project-update/history/blobs/d6e5b34d475e7187d2a8cb2aac7e7f1a427142baf6461bda387d70ceb4d8a8c1 +30 -0
- package/skills/project-update/history/blobs/e120ff4e32b2e950fc94ada4e787d57dc9c790da37223e5a202d74f7e3aadf80 +13 -0
- package/skills/project-update/history/blobs/e9adb7a9905eb719ac78ab96671f055da53e5001810f5ad6d6c94306b0323fe7 +74 -0
- package/skills/project-update/history/blobs/fae3064e648cdc55be4aeb1177525b302a5e34ebd752e4a904f47dc0f4557b96 +71 -0
- package/skills/project-update/history/index.json +60 -20
- package/skills/project-update/migrations/0009_state_file.py +25 -0
- package/skills/project-update/migrations/0010_dialog_switch.py +30 -0
- package/skills/project-update/migrations/0011_retire_risk_tiers.py +37 -0
- package/skills/project-update/migrations/README.md +2 -1
- package/skills/project-update/migrations/__init__.py +6 -1
- package/skills/project-update/render_instructions.py +1 -1
- package/skills/project-update/update.py +385 -89
- package/skills/rr-build/SKILL.md +14 -5
- package/skills/rr-build-all/SKILL.md +190 -0
- package/skills/rr-plan/SKILL.md +16 -4
- package/skills/rr-review/SKILL.md +24 -9
- package/skills/rr-setup/SKILL.md +15 -2
- package/skills/rr-setup/setup.py +7 -1
- package/skills/rr-ship/SKILL.md +16 -8
- package/skills/rr-spec/SKILL.md +4 -2
- package/skills/rr-status/SKILL.md +2 -2
- package/skills/usage-report/usage-report.py +45 -10
- package/skills/ai-init/templates/.ai/policies/risk-tiers.json +0 -628
- 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]}
|
package/hooks/guard/git.py
CHANGED
|
@@ -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,
|
|
11
|
-
is_file, is_jq_string, load_json_file,
|
|
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
|