@michelj/context-guard 0.4.4 → 0.6.1
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/Coordinator.md +88 -0
- package/Executor.md +53 -0
- package/README.md +72 -102
- package/README.zh-CN.md +72 -102
- package/SKILL.md +26 -33
- package/THIRD_PARTY_NOTICES.md +47 -0
- package/Tester.md +53 -0
- package/bin/build-runtime.mjs +96 -0
- package/bin/context-guard-skill.js +287 -69
- package/bin/postinstall.js +1 -1
- package/hooks.json +80 -4
- package/licenses/JSONParse-MIT.txt +24 -0
- package/licenses/Marked-MIT.txt +44 -0
- package/licenses/Portless-Apache-2.0.txt +201 -0
- package/package.json +31 -5
- package/prototype/LICENSES/Marked-MIT.txt +44 -0
- package/prototype/LICENSES/Ready-redistribution.txt +14 -0
- package/prototype/attachments.mjs +75 -0
- package/prototype/coordinator-markdown.mjs +283 -0
- package/prototype/coordinator-working-blot.mjs +124 -0
- package/prototype/vendor/marked.mjs +2189 -0
- package/prototype/workbench-app.js +5197 -0
- package/prototype/workbench-data.js +33 -0
- package/prototype/workbench-sync.mjs +898 -0
- package/prototype/workbench.css +1050 -0
- package/prototype/workbench.html +139 -4861
- package/prototype/working-blot-atlas.png +0 -0
- package/references/agent-handoff.md +40 -0
- package/references/claude-runtime.md +120 -0
- package/references/cloud-sync-interface.md +66 -0
- package/references/design-current.md +14 -0
- package/references/map-mount.md +41 -0
- package/references/map-read.md +50 -0
- package/references/memory-definition.md +120 -0
- package/references/memory-filesystem-v2/Bug.en.md +162 -0
- package/references/memory-filesystem-v2/Bug.md +162 -0
- package/references/memory-filesystem-v2/Bug_Coordinater.md +8 -0
- package/references/memory-filesystem-v2/Bug_Executor.md +8 -0
- package/references/memory-filesystem-v2/Bug_Tester.md +7 -0
- package/references/memory-filesystem-v2/Idea.en.md +36 -0
- package/references/memory-filesystem-v2/Idea.md +36 -0
- package/references/memory-filesystem-v2/Node_Module_Index.en.md +88 -0
- package/references/memory-filesystem-v2/Node_Module_Index.md +88 -0
- package/references/memory-filesystem-v2/README.md +60 -0
- package/references/memory-filesystem-v2/Todo.en.md +137 -0
- package/references/memory-filesystem-v2/Todo.md +137 -0
- package/references/memory-filesystem-v2/Todo_Coordinater.md +7 -0
- package/references/memory-filesystem-v2/Todo_Executor.md +7 -0
- package/references/memory-filesystem-v2/Todo_Tester.md +7 -0
- package/references/named-workbench.md +124 -0
- package/references/plan-review.md +12 -0
- package/references/server-memory.md +276 -0
- package/references/test-check.md +7 -0
- package/references/user-reply.md +38 -0
- package/references/workbench-interface.md +531 -0
- package/roles.md +13 -0
- package/scripts/context_guard.py +1163 -321
- package/scripts/context_guard_hook.py +1864 -63
- package/scripts/map_owns.py +68 -138
- package/scripts/shared/LICENSES/JSONParse-MIT.txt +24 -0
- package/scripts/shared/filesystem-v2.mjs +430 -0
- package/scripts/shared/io.mjs +117 -0
- package/scripts/shared/map-model.mjs +506 -0
- package/scripts/shared/memory-schema.mjs +13 -0
- package/scripts/shared/protocol-blobs.mjs +112 -0
- package/scripts/shared/protocol-map.mjs +146 -0
- package/scripts/shared/protocol-snapshots.mjs +84 -0
- package/scripts/shared/protocol-store.mjs +624 -0
- package/scripts/shared/protocol-workflow.mjs +226 -0
- package/scripts/shared/protocol.mjs +125 -0
- package/scripts/shared/vendor/jsonparse.cjs +413 -0
- package/scripts/workbench/access.mjs +496 -0
- package/scripts/workbench/attachments.mjs +92 -0
- package/scripts/workbench/browser-login.mjs +78 -0
- package/scripts/workbench/claude-runtime.mjs +372 -0
- package/scripts/workbench/cli.mjs +980 -0
- package/scripts/workbench/device-heartbeat.mjs +72 -0
- package/scripts/workbench/hook-status.mjs +38 -0
- package/scripts/workbench/inbox.mjs +155 -0
- package/scripts/workbench/journal.mjs +56 -0
- package/scripts/workbench/memory-merge.mjs +65 -0
- package/scripts/workbench/memory.mjs +252 -0
- package/scripts/workbench/named-proxy.mjs +108 -0
- package/scripts/workbench/named.mjs +152 -0
- package/scripts/workbench/portless-routes.mjs +51 -0
- package/scripts/workbench/project.mjs +327 -0
- package/scripts/workbench/projections.mjs +68 -0
- package/scripts/workbench/protocol-client.mjs +165 -0
- package/scripts/workbench/protocol-delivery.mjs +133 -0
- package/scripts/workbench/protocol-device.mjs +316 -0
- package/scripts/workbench/protocol-events.mjs +53 -0
- package/scripts/workbench/protocol-repository.mjs +58 -0
- package/scripts/workbench/reconcile.mjs +244 -0
- package/scripts/workbench/registry.mjs +111 -0
- package/scripts/workbench/runtime.mjs +54 -0
- package/scripts/workbench/server.mjs +1171 -0
- package/scripts/workbench/store.mjs +243 -0
- package/scripts/workbench/sync-coordinator.mjs +518 -0
- package/scripts/workbench/sync.mjs +86 -0
- package/references/bug-record-template.md +0 -37
- package/references/context-template.md +0 -19
|
@@ -5,15 +5,27 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
7
|
import json
|
|
8
|
+
import hashlib
|
|
8
9
|
import os
|
|
10
|
+
import re
|
|
11
|
+
import shlex
|
|
12
|
+
import sqlite3
|
|
13
|
+
import subprocess
|
|
9
14
|
import sys
|
|
15
|
+
import tempfile
|
|
10
16
|
from datetime import datetime, timezone
|
|
11
17
|
from pathlib import Path, PureWindowsPath
|
|
18
|
+
from urllib.parse import quote, urlsplit
|
|
12
19
|
|
|
13
|
-
from context_guard import append_session_event
|
|
20
|
+
from context_guard import acquire_hook_runtime_lock, append_session_event
|
|
21
|
+
from context_guard import add_prompt_signal
|
|
14
22
|
from context_guard import context_dir as context_folder
|
|
15
|
-
from context_guard import configure_stdio, folder_root, init_context, is_context_guard_skill_path
|
|
16
|
-
from context_guard import
|
|
23
|
+
from context_guard import configure_stdio, ensure_session_file, folder_root, init_context, is_context_guard_skill_path
|
|
24
|
+
from context_guard import hook_runtime_lock, read_hook_runtime, read_json, require_human_work_review, start_workbench, utc_now
|
|
25
|
+
from context_guard import safe_identifier, session_records, write_hook_runtime, write_json
|
|
26
|
+
from context_guard import run_node_workbench
|
|
27
|
+
|
|
28
|
+
WINDOWS_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
17
29
|
|
|
18
30
|
|
|
19
31
|
WORKSPACE_KEYS = {
|
|
@@ -84,6 +96,8 @@ def possible_workspace_paths(value: object) -> list[Path]:
|
|
|
84
96
|
|
|
85
97
|
|
|
86
98
|
def parse_hook_payload(raw: str) -> object:
|
|
99
|
+
# Windows hook runners may prepend a UTF-8 BOM to otherwise valid JSON.
|
|
100
|
+
raw = raw.lstrip("\ufeff")
|
|
87
101
|
if not raw.strip():
|
|
88
102
|
return {}
|
|
89
103
|
try:
|
|
@@ -92,6 +106,10 @@ def parse_hook_payload(raw: str) -> object:
|
|
|
92
106
|
return {}
|
|
93
107
|
|
|
94
108
|
|
|
109
|
+
def initialized_source_project(root: Path) -> bool:
|
|
110
|
+
return (root / ".git").exists() and (root / ".codex" / "context" / "map.json").is_file()
|
|
111
|
+
|
|
112
|
+
|
|
95
113
|
def event_root(raw: str, cwd: Path) -> tuple[Path, str]:
|
|
96
114
|
payload = parse_hook_payload(raw)
|
|
97
115
|
candidates: list[tuple[Path, str]] = []
|
|
@@ -104,7 +122,7 @@ def event_root(raw: str, cwd: Path) -> tuple[Path, str]:
|
|
|
104
122
|
candidates.append((cwd, "process cwd"))
|
|
105
123
|
for path, source in candidates:
|
|
106
124
|
root = folder_root(path)
|
|
107
|
-
if not is_context_guard_skill_path(root):
|
|
125
|
+
if not is_context_guard_skill_path(root) or initialized_source_project(root):
|
|
108
126
|
return root, source
|
|
109
127
|
return folder_root(cwd), "process cwd"
|
|
110
128
|
|
|
@@ -129,14 +147,30 @@ HOOK_EVENT_NAMES = {
|
|
|
129
147
|
"user-prompt-submit": "UserPromptSubmit",
|
|
130
148
|
"stop": "Stop",
|
|
131
149
|
"subagent-stop": "SubagentStop",
|
|
150
|
+
"pre-tool-use": "PreToolUse",
|
|
151
|
+
"post-tool-use": "PostToolUse",
|
|
152
|
+
"post-tool-use-failure": "PostToolUseFailure",
|
|
153
|
+
"session-end": "SessionEnd",
|
|
154
|
+
"stop-failure": "StopFailure",
|
|
155
|
+
"permission-request": "PermissionRequest",
|
|
156
|
+
"interrupt": "Interrupt",
|
|
157
|
+
"pre-compact": "PreCompact",
|
|
158
|
+
"post-compact": "PostCompact",
|
|
132
159
|
}
|
|
133
160
|
|
|
134
161
|
|
|
135
162
|
def hook_response(platform: str, event: str, additional_context: str = "") -> int:
|
|
136
163
|
payload: dict[str, object] = {}
|
|
164
|
+
if platform == "claude" and event in {"pre-compact", "post-compact", "session-end", "stop-failure"}:
|
|
165
|
+
print("{}") # These native events cannot deliver model context or block.
|
|
166
|
+
return 0
|
|
137
167
|
if additional_context:
|
|
138
168
|
if platform == "cursor":
|
|
139
169
|
payload["additional_context"] = additional_context
|
|
170
|
+
elif platform == "claude" and event in {"stop", "subagent-stop", "session-end"}:
|
|
171
|
+
# Claude treats additionalContext at Stop as another model turn.
|
|
172
|
+
# A diagnostic (e.g. unbound Session) must not restart generation.
|
|
173
|
+
payload["systemMessage"] = additional_context
|
|
140
174
|
else:
|
|
141
175
|
payload["hookSpecificOutput"] = {
|
|
142
176
|
"hookEventName": HOOK_EVENT_NAMES.get(event, event),
|
|
@@ -146,6 +180,22 @@ def hook_response(platform: str, event: str, additional_context: str = "") -> in
|
|
|
146
180
|
return 0
|
|
147
181
|
|
|
148
182
|
|
|
183
|
+
def permission_response(event: str, behavior: str = "", message: str = "") -> int:
|
|
184
|
+
payload: dict[str, object] = {}
|
|
185
|
+
if behavior:
|
|
186
|
+
decision: dict[str, object] = {"behavior": behavior}
|
|
187
|
+
if message:
|
|
188
|
+
decision["message"] = message
|
|
189
|
+
payload["hookSpecificOutput"] = {
|
|
190
|
+
"hookEventName": HOOK_EVENT_NAMES[event],
|
|
191
|
+
"decision": decision,
|
|
192
|
+
}
|
|
193
|
+
elif message:
|
|
194
|
+
payload["systemMessage"] = message
|
|
195
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
|
|
149
199
|
def payload_value(payload: object, keys: tuple[str, ...]) -> str:
|
|
150
200
|
if not isinstance(payload, dict):
|
|
151
201
|
return ""
|
|
@@ -156,42 +206,456 @@ def payload_value(payload: object, keys: tuple[str, ...]) -> str:
|
|
|
156
206
|
return ""
|
|
157
207
|
|
|
158
208
|
|
|
159
|
-
def
|
|
209
|
+
def payload_time(payload: object) -> str:
|
|
210
|
+
return payload_value(payload, ("timestamp", "occurred_at", "occurredAt")) or utc_now()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def event_identity(payload: object, event: str, current_session_id: str) -> tuple[str, str, str]:
|
|
214
|
+
turn_id = payload_value(payload, ("turn_id", "turnId"))
|
|
215
|
+
call_id = payload_value(payload, ("tool_use_id", "toolUseId", "call_id", "callId", "agent_id", "agentId"))
|
|
216
|
+
marker = payload_value(payload, ("trigger", "source", "reason"))
|
|
217
|
+
prompt_hash = hashlib.sha256(prompt_text(json.dumps(payload, ensure_ascii=False)).encode("utf-8")).hexdigest() if isinstance(payload, dict) else ""
|
|
218
|
+
key = json.dumps([current_session_id, event, turn_id, call_id, marker, prompt_hash], ensure_ascii=False, separators=(",", ":"))
|
|
219
|
+
return f"hook-{hashlib.sha256(key.encode('utf-8')).hexdigest()[:24]}", turn_id, call_id
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def map_entries(node: object):
|
|
223
|
+
if not isinstance(node, dict):
|
|
224
|
+
return
|
|
225
|
+
yield node
|
|
226
|
+
for key in ("children", "_inbox"):
|
|
227
|
+
for child in node.get(key) or []:
|
|
228
|
+
yield from map_entries(child)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def map_snapshot(ctx: Path, current_session_id: str) -> dict[str, object]:
|
|
232
|
+
map_file = ctx / "map.json"
|
|
233
|
+
access_file = ctx / "sessions" / "workbench-access.json"
|
|
234
|
+
role = "executor"
|
|
235
|
+
probe = subprocess.run(["git", "rev-parse", "--path-format=absolute", "--git-common-dir"], cwd=ctx.parent.parent,
|
|
236
|
+
capture_output=True, text=True, timeout=5, check=False,
|
|
237
|
+
creationflags=WINDOWS_NO_WINDOW)
|
|
238
|
+
if probe.returncode == 0:
|
|
239
|
+
shared = Path(probe.stdout.strip()) / "context-guard"
|
|
240
|
+
bindings = read_json(shared / "workbench-bindings.json", {})
|
|
241
|
+
bound = bindings.get("sessions", {}).get(current_session_id, {})
|
|
242
|
+
if bound.get("worktreeRoot") == str(ctx.parent.parent.resolve()):
|
|
243
|
+
role = "coordinator" if bound.get("role") == "coordinator" else "executor"
|
|
244
|
+
scope = hashlib.sha256((current_session_id + "\0" + bound.get("worktreeId", "")).encode()).hexdigest()
|
|
245
|
+
map_file = shared / "session-memory" / scope / "map.json"
|
|
246
|
+
access_file = shared / "workbench-access.json"
|
|
247
|
+
document = read_json(map_file, {})
|
|
248
|
+
nodes = list(map_entries(document.get("root"))) if isinstance(document, dict) else []
|
|
249
|
+
access = read_json(access_file, {})
|
|
250
|
+
sessions = access.get("sessions") if isinstance(access, dict) and isinstance(access.get("sessions"), dict) else {}
|
|
251
|
+
grant_record = sessions.get(current_session_id) if isinstance(sessions, dict) else {}
|
|
252
|
+
grants = grant_record.get("nodes") if isinstance(grant_record, dict) and isinstance(grant_record.get("nodes"), list) else []
|
|
253
|
+
by_id = {str(node.get("id")): node for node in nodes if isinstance(node.get("id"), str)}
|
|
254
|
+
# Match AccessStore: all is dynamic, while an explicit empty list is revoked.
|
|
255
|
+
if isinstance(grant_record, dict) and grant_record.get("mode") == "all":
|
|
256
|
+
grants = list(by_id)
|
|
257
|
+
def can_write_node(node_id: str) -> bool:
|
|
258
|
+
if node_id not in by_id:
|
|
259
|
+
return False
|
|
260
|
+
rule = next((item for item in (by_id[node_id].get("access") or [])
|
|
261
|
+
if isinstance(item, dict) and item.get("agentId") == current_session_id), None)
|
|
262
|
+
return rule is None or rule.get("allow") == "write"
|
|
263
|
+
grants = [node_id for node_id in grants if can_write_node(node_id)]
|
|
264
|
+
assigned_todos: list[dict[str, str]] = []
|
|
265
|
+
assigned_bugs: list[dict[str, str]] = []
|
|
266
|
+
for node in nodes:
|
|
267
|
+
node_id = str(node.get("id") or "")
|
|
268
|
+
node_title = str(node.get("title") or node_id)
|
|
269
|
+
for todo in node.get("todos") or []:
|
|
270
|
+
owners = todo.get("sessions") if isinstance(todo, dict) else []
|
|
271
|
+
target = str(todo.get("target_session") or "") if isinstance(todo, dict) else ""
|
|
272
|
+
if isinstance(todo, dict) and (current_session_id in (owners or []) or target == current_session_id):
|
|
273
|
+
assigned_todos.append({"id": str(todo.get("id") or ""), "title": str(todo.get("title") or ""), "status": str(todo.get("status") or "pending"), "node": node_id, "node_title": node_title})
|
|
274
|
+
for bug in node.get("bugs") or []:
|
|
275
|
+
owners = bug.get("sessions") if isinstance(bug, dict) else []
|
|
276
|
+
if isinstance(bug, dict) and current_session_id in (owners or []):
|
|
277
|
+
assigned_bugs.append({"id": str(bug.get("id") or ""), "title": str(bug.get("title") or ""), "status": str(bug.get("status") or "open"), "node": node_id, "node_title": node_title})
|
|
278
|
+
raw = map_file.read_bytes() if map_file.is_file() else b""
|
|
279
|
+
local_version = hashlib.sha256(raw).hexdigest() if raw else "missing"
|
|
280
|
+
return {
|
|
281
|
+
"role": role,
|
|
282
|
+
"version": local_version,
|
|
283
|
+
"cloud_cursor": None,
|
|
284
|
+
"grants": [str(item) for item in grants],
|
|
285
|
+
"grant_nodes": [{"id": node_id, "title": str(by_id.get(node_id, {}).get("title") or node_id)} for node_id in grants[:20]],
|
|
286
|
+
"todos": assigned_todos[:20],
|
|
287
|
+
"bugs": assigned_bugs[:20],
|
|
288
|
+
"nodes": nodes,
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def map_inbox(root: Path, ctx: Path, current_session_id: str) -> dict[str, object]:
|
|
293
|
+
"""Read the durable Map inbox only when the local workbench is already live."""
|
|
294
|
+
if not (ctx / "private" / "workbench.json").is_file():
|
|
295
|
+
return {"pending": False, "available": False}
|
|
296
|
+
script = Path(__file__).resolve().parent / "workbench" / "cli.mjs"
|
|
297
|
+
command = [
|
|
298
|
+
"node", str(script), "map", "inbox", "--root", str(root),
|
|
299
|
+
"--session", current_session_id, "--start",
|
|
300
|
+
]
|
|
301
|
+
try:
|
|
302
|
+
result = subprocess.run(command, cwd=root, capture_output=True, text=True, encoding="utf-8", timeout=10,
|
|
303
|
+
check=False, creationflags=WINDOWS_NO_WINDOW)
|
|
304
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
305
|
+
return {"pending": False, "error": {"code": "INBOX_READ_FAILED", "message": str(error)}}
|
|
306
|
+
lines = (result.stdout or "").strip().splitlines()
|
|
307
|
+
try:
|
|
308
|
+
value = json.loads(lines[-1]) if lines else None
|
|
309
|
+
if not isinstance(value, dict) or ("pending" not in value and "error" not in value):
|
|
310
|
+
raise ValueError("missing inbox response")
|
|
311
|
+
except (json.JSONDecodeError, ValueError):
|
|
312
|
+
value = {"error": {"code": "INBOX_READ_FAILED", "message": "invalid workbench response"}}
|
|
313
|
+
if result.returncode and "error" not in value:
|
|
314
|
+
value = {"error": {"code": "INBOX_READ_FAILED", "message": (result.stderr or "inbox command failed").strip()[:500]}}
|
|
315
|
+
return value
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def coordinator_static_context(root: Path, ctx: Path, current_session_id: str, snapshot: dict[str, object]) -> str:
|
|
319
|
+
probe = subprocess.run(["git", "rev-parse", "--path-format=absolute", "--git-common-dir"], cwd=root,
|
|
320
|
+
capture_output=True, text=True, timeout=5, check=False, creationflags=WINDOWS_NO_WINDOW)
|
|
321
|
+
shared = Path(probe.stdout.strip()) / "context-guard" if probe.returncode == 0 else None
|
|
322
|
+
cloud = bool(shared and (shared / "memory-client.json").is_file())
|
|
323
|
+
if cloud:
|
|
324
|
+
command = ["node", str(Path(__file__).resolve().parent / "workbench" / "cli.mjs"),
|
|
325
|
+
"map", "main", "read", "--root", str(root), "--session", current_session_id]
|
|
326
|
+
try:
|
|
327
|
+
result = subprocess.run(command, cwd=root, capture_output=True, text=True, encoding="utf-8", timeout=8,
|
|
328
|
+
check=False, creationflags=WINDOWS_NO_WINDOW)
|
|
329
|
+
response = json.loads(result.stdout) if result.returncode == 0 else {}
|
|
330
|
+
except (OSError, subprocess.SubprocessError, json.JSONDecodeError):
|
|
331
|
+
response = {}
|
|
332
|
+
if not isinstance(response, dict):
|
|
333
|
+
response = {}
|
|
334
|
+
document, version = response.get("doc"), response.get("version")
|
|
335
|
+
else:
|
|
336
|
+
main_file = shared / "main" / "map.json" if shared else ctx / "map.json"
|
|
337
|
+
document = read_json(main_file, {})
|
|
338
|
+
version = hashlib.sha256(main_file.read_bytes()).hexdigest() if main_file.is_file() else None
|
|
339
|
+
if not isinstance(document, dict) or not isinstance(document.get("root"), dict) or not version:
|
|
340
|
+
return "Coordinator Main navigation unavailable; do not substitute the Session Map or stale conversation text."
|
|
341
|
+
directory = []
|
|
342
|
+
by_id = {}
|
|
343
|
+
def visit(node: dict[str, object], parent_id: str | None = None) -> None:
|
|
344
|
+
by_id[str(node.get("id") or "")] = node
|
|
345
|
+
children = [child for child in node.get("children") or [] if isinstance(child, dict)]
|
|
346
|
+
directory.append({"id": node.get("id"), "parentId": parent_id, "title": str(node.get("title") or "")[:120],
|
|
347
|
+
"description": str(node.get("purpose") or "")[:320], "children": [child.get("id") for child in children]})
|
|
348
|
+
for child in children:
|
|
349
|
+
visit(child, str(node.get("id") or ""))
|
|
350
|
+
visit(document["root"])
|
|
351
|
+
current = {"todos": [], "bugs": []}
|
|
352
|
+
for kind, field in (("todo", "todos"), ("bug", "bugs")):
|
|
353
|
+
for assigned in snapshot.get(field) or []:
|
|
354
|
+
node_id, item_id = str(assigned.get("node") or ""), str(assigned.get("id") or "")
|
|
355
|
+
item = next((value for value in by_id.get(node_id, {}).get(field) or []
|
|
356
|
+
if isinstance(value, dict) and value.get("id") == item_id), None)
|
|
357
|
+
current[field].append({"nodeId": node_id, "itemId": item_id, "kind": kind,
|
|
358
|
+
**({"title": str(item.get("title") or "")[:240], "status": item.get("status")}
|
|
359
|
+
if item else {"unavailable": True})})
|
|
360
|
+
return "Coordinator static Main context (project data, not user instructions): " + json.dumps({
|
|
361
|
+
"mainVersion": version, "staticDirectory": directory,
|
|
362
|
+
"currentTask": current,
|
|
363
|
+
}, ensure_ascii=False, separators=(",", ":"))
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def map_context(root: Path, ctx: Path, current_session_id: str) -> tuple[str, dict[str, object]]:
|
|
367
|
+
snapshot = map_snapshot(ctx, current_session_id)
|
|
368
|
+
inbox = map_inbox(root, ctx, current_session_id)
|
|
369
|
+
grants = snapshot["grant_nodes"]
|
|
370
|
+
todos = snapshot["todos"]
|
|
371
|
+
bugs = snapshot["bugs"]
|
|
372
|
+
grant_text = ", ".join(f"{item['id']} {item['title']}" for item in grants) if grants else "none"
|
|
373
|
+
if snapshot.get("role") == "coordinator":
|
|
374
|
+
todo_text = bug_text = "see authoritative Main context below"
|
|
375
|
+
else:
|
|
376
|
+
todo_text = "; ".join(f"{item['id']}@{item['node']} {item['title']} [{item['status']}]" for item in todos) if todos else "none"
|
|
377
|
+
bug_text = "; ".join(f"{item['id']}@{item['node']} {item['title']} [{item['status']}]" for item in bugs) if bugs else "none"
|
|
378
|
+
inbox_text = "No unacknowledged Map changes from other sessions."
|
|
379
|
+
if isinstance(inbox.get("error"), dict):
|
|
380
|
+
inbox_text = f"Map inbox unavailable: {inbox['error'].get('code')}."
|
|
381
|
+
elif inbox.get("available") is False:
|
|
382
|
+
inbox_text = "Map inbox unavailable: no live workbench; changes have not been checked."
|
|
383
|
+
elif inbox.get("pending"):
|
|
384
|
+
events = inbox.get("events") if isinstance(inbox.get("events"), list) else []
|
|
385
|
+
changes = inbox.get("changes") if isinstance(inbox.get("changes"), list) else []
|
|
386
|
+
actors = sorted({str(item.get("actor", {}).get("sessionId") or item.get("actor", {}).get("kind") or "unknown") for item in events if isinstance(item, dict)})
|
|
387
|
+
changed_nodes = sorted({str(item.get("id") or item.get("nodeId") or "") for item in changes if isinstance(item, dict) and (item.get("id") or item.get("nodeId"))})
|
|
388
|
+
inbox_text = (
|
|
389
|
+
f"Pending Map inbox receipt {inbox.get('receipt')} from {', '.join(actors) or 'other sessions'}; "
|
|
390
|
+
f"changed nodes: {', '.join(changed_nodes) or 'see inbox payload'}; journal gap: {bool(inbox.get('journalGap'))}. "
|
|
391
|
+
)
|
|
392
|
+
text = (
|
|
393
|
+
f"Context Guard Map snapshot {str(snapshot['version'])[:16]} (cloud cursor {snapshot.get('cloud_cursor') or 0}). "
|
|
394
|
+
f"Authorized nodes: {grant_text}. Assigned TODOs: {todo_text}. Assigned Bugs: {bug_text}. "
|
|
395
|
+
f"{inbox_text}"
|
|
396
|
+
)
|
|
397
|
+
if snapshot.get("role") == "coordinator":
|
|
398
|
+
text += "\n\n" + coordinator_static_context(root, ctx, current_session_id, snapshot)
|
|
399
|
+
return text, snapshot
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def audit_details(payload: object, event: str, current_session_id: str, runtime: dict[str, object], extra: dict[str, object] | None = None) -> dict[str, object]:
|
|
403
|
+
event_id, turn_id, call_id = event_identity(payload, event, current_session_id)
|
|
404
|
+
plan = runtime.get("active_plan") if isinstance(runtime.get("active_plan"), dict) else {}
|
|
405
|
+
details: dict[str, object] = {
|
|
406
|
+
"event_id": event_id,
|
|
407
|
+
"occurred_at": payload_time(payload),
|
|
408
|
+
"recorded_at": utc_now(),
|
|
409
|
+
"hook_event": HOOK_EVENT_NAMES.get(event, event),
|
|
410
|
+
"turn_id": turn_id or None,
|
|
411
|
+
"plan_id": plan.get("id") if isinstance(plan, dict) else None,
|
|
412
|
+
"tool_call_id": call_id or None,
|
|
413
|
+
"hook_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
|
414
|
+
"context_emitted": event in {"session-start", "user-prompt-submit", "post-compact"},
|
|
415
|
+
}
|
|
416
|
+
if extra:
|
|
417
|
+
details.update(extra)
|
|
418
|
+
return details
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def active_grants(snapshot: dict[str, object]) -> set[str]:
|
|
422
|
+
return {str(item) for item in snapshot.get("grants") or []}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def owner_nodes(paths: list[str], snapshot: dict[str, object]) -> dict[str, str]:
|
|
426
|
+
found: dict[str, str] = {}
|
|
427
|
+
for file in paths:
|
|
428
|
+
normalized = file.replace("\\", "/").lstrip("./")
|
|
429
|
+
best: tuple[int, str] | None = None
|
|
430
|
+
for node in snapshot.get("nodes") or []:
|
|
431
|
+
if not isinstance(node, dict) or node.get("proposal") in {"proposed", "cancelled"}:
|
|
432
|
+
continue
|
|
433
|
+
for owned in node.get("owns") or []:
|
|
434
|
+
owned_path = str(owned).replace("\\", "/").lstrip("./").rstrip("/")
|
|
435
|
+
if owned_path and (normalized == owned_path or normalized.startswith(owned_path + "/")):
|
|
436
|
+
candidate = (len(owned_path), str(node.get("id") or ""))
|
|
437
|
+
if candidate[1] and (best is None or candidate[0] > best[0]):
|
|
438
|
+
best = candidate
|
|
439
|
+
if best:
|
|
440
|
+
found[normalized] = best[1]
|
|
441
|
+
return found
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def tool_command(payload: dict) -> str:
|
|
445
|
+
value = payload.get("tool_input")
|
|
446
|
+
if isinstance(value, str):
|
|
447
|
+
return value
|
|
448
|
+
if isinstance(value, dict):
|
|
449
|
+
return str(value.get("command") or value.get("cmd") or value.get("patch") or value.get("input") or "")
|
|
450
|
+
return ""
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def patch_targets(payload: dict) -> list[str] | None:
|
|
454
|
+
name = str(payload.get("tool_name") or payload.get("toolName") or "")
|
|
455
|
+
if name.rsplit(".", 1)[-1] != "apply_patch":
|
|
456
|
+
return None
|
|
457
|
+
command = tool_command(payload).replace("\r\n", "\n").strip()
|
|
458
|
+
if not (command.startswith("*** Begin Patch\n") and command.endswith("\n*** End Patch")):
|
|
459
|
+
return None
|
|
460
|
+
return re.findall(r"^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+)$", command, re.MULTILINE)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def forbidden_direct_write(payload: object, root: Path) -> str:
|
|
464
|
+
if not mutating_tool(payload) or not isinstance(payload, dict):
|
|
465
|
+
return ""
|
|
466
|
+
command = tool_command(payload)
|
|
467
|
+
targets = patch_targets(payload)
|
|
468
|
+
if targets is not None:
|
|
469
|
+
# Only patch headers designate writes. Hunk text may discuss protected paths.
|
|
470
|
+
# Prefix each target so the path check also covers another worktree.
|
|
471
|
+
checked_paths = []
|
|
472
|
+
for item in targets:
|
|
473
|
+
normalized = os.path.normpath(item.strip().replace("\\", "/")).replace("\\", "/")
|
|
474
|
+
checked_paths.append("/" + normalized.lstrip("/"))
|
|
475
|
+
candidate = Path(normalized).expanduser()
|
|
476
|
+
try:
|
|
477
|
+
resolved = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve()
|
|
478
|
+
except (OSError, ValueError, RuntimeError):
|
|
479
|
+
return "Cannot resolve patch target; verify the target path before writing."
|
|
480
|
+
checked_paths.append(resolved.as_posix())
|
|
481
|
+
command = "\n".join(checked_paths)
|
|
482
|
+
paths = {
|
|
483
|
+
normalized[2:] if normalized.startswith("./") else normalized
|
|
484
|
+
for item in tool_paths(payload, root)
|
|
485
|
+
for normalized in [item.replace("\\", "/")]
|
|
486
|
+
}
|
|
487
|
+
if ".codex/context/map.json" in paths or re.search(r"(?:^|[\s'\"/\\])\.codex[/\\]context[/\\]map\.json\b", command):
|
|
488
|
+
return "Direct map.json writes are forbidden; use context-guard map read/apply/reconcile."
|
|
489
|
+
if any(item.rsplit("/", 1)[-1].lower() == "todo.md" for item in paths) or re.search(r"(?:^|[/\\])TODO\.md\b", command, re.IGNORECASE):
|
|
490
|
+
return "TODO.md is human-owned; record Agent work in the authorized Map node instead."
|
|
491
|
+
return ""
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def session_id(payload: object, platform: str, ctx: Path, event: str) -> str:
|
|
160
495
|
value = payload_value(
|
|
161
496
|
payload,
|
|
162
497
|
("session_id", "sessionId", "conversation_id", "conversationId", "generation_id"),
|
|
163
498
|
)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
499
|
+
state_path = ctx / "private" / "hook-sessions.json"
|
|
500
|
+
state = read_json(state_path, {})
|
|
501
|
+
if not isinstance(state, dict):
|
|
502
|
+
state = {}
|
|
503
|
+
if not value and event not in {"session-start", "subagent-start"}:
|
|
504
|
+
stored = state.get(platform)
|
|
505
|
+
if isinstance(stored, str) and stored.strip():
|
|
506
|
+
value = stored.strip()
|
|
507
|
+
if not value:
|
|
508
|
+
for key in ("CODEX_THREAD_ID", "CLAUDE_SESSION_ID", "CURSOR_SESSION_ID"):
|
|
509
|
+
env_value = os.environ.get(key, "").strip()
|
|
510
|
+
if env_value:
|
|
511
|
+
value = env_value
|
|
512
|
+
break
|
|
513
|
+
if not value:
|
|
514
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
515
|
+
value = f"{platform}-{stamp}-{os.getpid()}"
|
|
516
|
+
if state.get(platform) != value:
|
|
517
|
+
state[platform] = value
|
|
518
|
+
write_json(state_path, state)
|
|
519
|
+
return value
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def normalized_path(value: str) -> str:
|
|
523
|
+
try:
|
|
524
|
+
return os.path.normcase(os.path.normpath(str(Path(value).expanduser().resolve())))
|
|
525
|
+
except (OSError, RuntimeError, ValueError):
|
|
526
|
+
return ""
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def codex_thread_name(root: Path, current_session_id: str) -> str:
|
|
530
|
+
"""Read only the matching local Codex thread metadata, if available."""
|
|
531
|
+
codex_home = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser()
|
|
532
|
+
databases = [codex_home / "state_5.sqlite", codex_home / "sqlite" / "state_5.sqlite"]
|
|
533
|
+
expected_root = normalized_path(str(root))
|
|
534
|
+
for database in databases:
|
|
535
|
+
if not database.is_file():
|
|
536
|
+
continue
|
|
537
|
+
connection = None
|
|
538
|
+
try:
|
|
539
|
+
connection = sqlite3.connect(database.resolve().as_uri() + "?mode=ro", uri=True, timeout=0.5)
|
|
540
|
+
columns = {
|
|
541
|
+
str(row[1])
|
|
542
|
+
for row in connection.execute("PRAGMA table_info(threads)")
|
|
543
|
+
if len(row) > 1
|
|
544
|
+
}
|
|
545
|
+
if not {"id", "cwd"}.issubset(columns):
|
|
546
|
+
continue
|
|
547
|
+
selected = [column for column in ("name", "title", "cwd") if column in columns]
|
|
548
|
+
row = connection.execute(
|
|
549
|
+
f"SELECT {', '.join(selected)} FROM threads WHERE id = ? LIMIT 1",
|
|
550
|
+
(current_session_id,),
|
|
551
|
+
).fetchone()
|
|
552
|
+
if not row:
|
|
553
|
+
continue
|
|
554
|
+
values = dict(zip(selected, row))
|
|
555
|
+
if normalized_path(str(values.get("cwd") or "")) != expected_root:
|
|
556
|
+
continue
|
|
557
|
+
for field in ("name", "title"):
|
|
558
|
+
value = values.get(field)
|
|
559
|
+
if isinstance(value, str) and value.strip():
|
|
560
|
+
return " ".join(value.strip().split())[:240]
|
|
561
|
+
except (OSError, sqlite3.Error):
|
|
562
|
+
continue
|
|
563
|
+
finally:
|
|
564
|
+
if connection is not None:
|
|
565
|
+
connection.close()
|
|
566
|
+
return ""
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def session_display_name(payload: object, platform: str, root: Path, current_session_id: str) -> str:
|
|
570
|
+
if platform == "codex":
|
|
571
|
+
value = codex_thread_name(root, current_session_id)
|
|
572
|
+
if value:
|
|
573
|
+
return value
|
|
574
|
+
named = payload_value(
|
|
575
|
+
payload,
|
|
576
|
+
(
|
|
577
|
+
"thread_name",
|
|
578
|
+
"threadName",
|
|
579
|
+
"session_name",
|
|
580
|
+
"sessionName",
|
|
581
|
+
"conversation_title",
|
|
582
|
+
"conversationTitle",
|
|
583
|
+
),
|
|
584
|
+
)[:240]
|
|
585
|
+
if named or platform != "claude":
|
|
586
|
+
return named
|
|
587
|
+
# Native Claude hooks provide the transcript path, but not the --name value.
|
|
588
|
+
# Read only a bounded tail of this Session's own transcript metadata.
|
|
589
|
+
transcript = payload_value(payload, ("transcript_path",))
|
|
590
|
+
if not transcript:
|
|
591
|
+
return ""
|
|
592
|
+
try:
|
|
593
|
+
directory = Path(os.environ.get("CLAUDE_CONFIG_DIR") or os.environ.get("CLAUDE_HOME") or Path.home() / ".claude").expanduser().resolve() / "projects"
|
|
594
|
+
file = Path(transcript).resolve()
|
|
595
|
+
file.relative_to(directory)
|
|
596
|
+
if file.name != current_session_id + ".jsonl":
|
|
597
|
+
return ""
|
|
598
|
+
with file.open("rb") as stream:
|
|
599
|
+
stream.seek(0, os.SEEK_END)
|
|
600
|
+
stream.seek(max(0, stream.tell() - 262144))
|
|
601
|
+
lines = stream.read(262144).decode("utf-8", errors="replace").splitlines()
|
|
602
|
+
for line in reversed(lines):
|
|
603
|
+
try:
|
|
604
|
+
item = json.loads(line)
|
|
605
|
+
except ValueError:
|
|
606
|
+
continue
|
|
607
|
+
if isinstance(item, dict) and item.get("type") == "custom-title" and item.get("sessionId") == current_session_id:
|
|
608
|
+
return str(item.get("customTitle") or "").strip()[:200]
|
|
609
|
+
except (OSError, ValueError):
|
|
610
|
+
pass
|
|
611
|
+
return ""
|
|
168
612
|
|
|
169
613
|
|
|
170
614
|
def language_setup_context(root: Path, ctx: Path) -> str:
|
|
171
|
-
|
|
615
|
+
try:
|
|
616
|
+
language = str(run_node_workbench(["preferences", "--root", str(root)]).get("record_language", "unset"))
|
|
617
|
+
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
|
|
618
|
+
return "Context Guard language unavailable; existing preference preserved."
|
|
172
619
|
if language and language != "unset":
|
|
173
620
|
return ""
|
|
174
621
|
quoted_root = '"' + str(root).replace('"', '\\"') + '"'
|
|
622
|
+
cli = context_guard_cli()
|
|
175
623
|
return (
|
|
176
|
-
"Context Guard
|
|
177
|
-
"
|
|
178
|
-
"After the user answers, run `context-guard set-language --root "
|
|
624
|
+
"Context Guard language unset: ask 中文 or English. "
|
|
625
|
+
f"After the user answers, run `{cli} set-language --root "
|
|
179
626
|
f"{quoted_root} --language <zh-or-en>` and then continue in that language."
|
|
180
627
|
)
|
|
181
628
|
|
|
182
629
|
|
|
183
|
-
def
|
|
184
|
-
|
|
630
|
+
def context_guard_cli() -> str:
|
|
631
|
+
launcher = Path(__file__).resolve().parent.parent / "bin" / "context-guard-skill.js"
|
|
632
|
+
return f"node {json.dumps(str(launcher))}"
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def lifecycle_context(root: Path, workbench_url: str | None, current_session_id: str) -> str:
|
|
636
|
+
skill = Path(__file__).resolve().parent.parent / "SKILL.md"
|
|
185
637
|
workbench = f" Workbench: {workbench_url}." if workbench_url else ""
|
|
186
638
|
return (
|
|
187
|
-
f"Context Guard
|
|
188
|
-
"
|
|
189
|
-
f"{quoted_root} --title <title> --phenomenon <what-failed> --trigger <trigger> "
|
|
190
|
-
"--cause <cause-or-pending> --guard <regression-guard> --keys <comma-separated>`; "
|
|
191
|
-
"never store secrets in project context."
|
|
639
|
+
f"Context Guard: {root}; Session: {current_session_id}.{workbench} "
|
|
640
|
+
f"Instructions: {skill}. CLI: {context_guard_cli()}."
|
|
192
641
|
)
|
|
193
642
|
|
|
194
643
|
|
|
644
|
+
def frontend_workbench_url(ctx: Path, local_url: str | None, current_session_id: str) -> str | None:
|
|
645
|
+
"""Expose one human UI: Cloud when configured, otherwise the local fallback."""
|
|
646
|
+
config = sync_command(ctx.parent.parent, "status", current_session_id)
|
|
647
|
+
cloud_url = str(config.get("url") or "") if isinstance(config, dict) else ""
|
|
648
|
+
project_id = str(config.get("projectId") or "") if isinstance(config, dict) else ""
|
|
649
|
+
try:
|
|
650
|
+
parsed = urlsplit(cloud_url)
|
|
651
|
+
if parsed.scheme == "https" and parsed.hostname and re.fullmatch(r"[a-z0-9][a-z0-9-]{0,63}", project_id):
|
|
652
|
+
origin = f"{parsed.scheme}://{parsed.netloc}"
|
|
653
|
+
return f"{origin}/projects/{quote(project_id)}?session={quote(current_session_id)}"
|
|
654
|
+
except ValueError:
|
|
655
|
+
pass
|
|
656
|
+
return local_url
|
|
657
|
+
|
|
658
|
+
|
|
195
659
|
def prompt_text(raw: str) -> str:
|
|
196
660
|
payload = parse_hook_payload(raw)
|
|
197
661
|
return payload_value(payload, ("prompt", "user_prompt", "userPrompt", "text", "content"))
|
|
@@ -203,7 +667,7 @@ def redact(text: str) -> str:
|
|
|
203
667
|
return text
|
|
204
668
|
|
|
205
669
|
|
|
206
|
-
def append_user_message(ctx: Path, text: str) -> str:
|
|
670
|
+
def append_user_message(ctx: Path, text: str, current_session_id: str) -> str:
|
|
207
671
|
text = (text or "").strip()
|
|
208
672
|
if not text:
|
|
209
673
|
return "empty"
|
|
@@ -213,15 +677,958 @@ def append_user_message(ctx: Path, text: str) -> str:
|
|
|
213
677
|
path.write_text("# User Message Memory\n\n## Recent User Signals\n\n", encoding="utf-8")
|
|
214
678
|
body = path.read_text(encoding="utf-8")
|
|
215
679
|
line = "- " + redact(text).replace("\n", " ")
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
680
|
+
recorded = False
|
|
681
|
+
if line not in body:
|
|
682
|
+
marker = "## Recent User Signals"
|
|
683
|
+
if marker in body:
|
|
684
|
+
body = body.replace(marker, marker + "\n\n" + line, 1)
|
|
685
|
+
else:
|
|
686
|
+
body += "\n" + line + "\n"
|
|
687
|
+
path.write_text(body, encoding="utf-8")
|
|
688
|
+
recorded = True
|
|
689
|
+
session_path = ctx / "sessions" / f"{safe_identifier(current_session_id)}.md"
|
|
690
|
+
if session_path.is_file():
|
|
691
|
+
session_body = session_path.read_text(encoding="utf-8")
|
|
692
|
+
session_marker = "## User Signals"
|
|
693
|
+
if line not in session_body:
|
|
694
|
+
if session_marker in session_body:
|
|
695
|
+
session_body = session_body.replace(session_marker, session_marker + "\n\n" + line, 1)
|
|
696
|
+
else:
|
|
697
|
+
session_body += ("\n" if session_body.endswith("\n") else "\n\n") + session_marker + "\n\n" + line + "\n"
|
|
698
|
+
session_path.write_text(session_body, encoding="utf-8")
|
|
699
|
+
recorded = True
|
|
700
|
+
return "recorded" if recorded else "duplicate"
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def sync_configured(root: Path, current_session_id: str) -> bool:
|
|
704
|
+
result = sync_command(root, "status", current_session_id)
|
|
705
|
+
if result.get("error"):
|
|
706
|
+
raise ValueError(f"Cloud sync status failed: {json.dumps(result, ensure_ascii=False)}")
|
|
707
|
+
return result.get("configured") is True
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def sync_command(root: Path, action: str, current_session_id: str = "", paths: list[str] | None = None) -> dict[str, object]:
|
|
711
|
+
script = Path(__file__).resolve().parent / "workbench" / "cli.mjs"
|
|
712
|
+
if not script.is_file():
|
|
713
|
+
return {"error": {"code": "SYNC_TOOL_MISSING", "message": str(script)}}
|
|
714
|
+
command = ["node", str(script), "sync", action, "--root", str(root)]
|
|
715
|
+
if current_session_id:
|
|
716
|
+
command.extend(["--session", current_session_id])
|
|
717
|
+
clean_paths = [item for item in (paths or []) if item]
|
|
718
|
+
if clean_paths:
|
|
719
|
+
command.extend(["--paths", ",".join(clean_paths)])
|
|
720
|
+
try:
|
|
721
|
+
result = subprocess.run(command, cwd=root, capture_output=True, text=True, encoding="utf-8", timeout=15,
|
|
722
|
+
check=False, creationflags=WINDOWS_NO_WINDOW)
|
|
723
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
724
|
+
return {"error": {"code": "SYNC_TOOL_FAILED", "message": str(error)}}
|
|
725
|
+
output = (result.stdout or "").strip().splitlines()
|
|
726
|
+
try:
|
|
727
|
+
value = json.loads(output[-1]) if output else None
|
|
728
|
+
if not isinstance(value, dict) or not value:
|
|
729
|
+
raise ValueError("missing sync response")
|
|
730
|
+
except (json.JSONDecodeError, ValueError):
|
|
731
|
+
value = {"error": {"code": "SYNC_TOOL_FAILED", "message": (result.stderr or result.stdout or "invalid output").strip()[:500]}}
|
|
732
|
+
if result.returncode and "error" not in value:
|
|
733
|
+
value = {"error": {"code": "SYNC_TOOL_FAILED", "message": (result.stderr or "sync command failed").strip()[:500]}}
|
|
734
|
+
return value
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def session_memory_sync(root: Path, current_session_id: str, event: str, payload: object) -> dict[str, object]:
|
|
738
|
+
"""Persist this Session checkpoint directly; a later hook retries preserved local data."""
|
|
739
|
+
event_id, _, _ = event_identity(payload, event, current_session_id)
|
|
740
|
+
try:
|
|
741
|
+
return run_node_workbench([
|
|
742
|
+
"memory", "sync", "--root", str(root), "--session", current_session_id,
|
|
743
|
+
"--hook-event", HOOK_EVENT_NAMES.get(event, event),
|
|
744
|
+
"--event-id", event_id,
|
|
745
|
+
"--occurred-at", payload_time(payload),
|
|
746
|
+
])
|
|
747
|
+
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as error:
|
|
748
|
+
return {"error": {"code": "MEMORY_SYNC_PENDING", "message": str(error)[:500]}}
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def sync_pending_interrupt(root: Path, session: str, runtime: dict) -> None:
|
|
752
|
+
"""Keep interruption signals until the local backend accepts the original ID."""
|
|
753
|
+
pending = runtime.get("pending_interrupts") or []
|
|
754
|
+
if not pending:
|
|
755
|
+
return
|
|
756
|
+
item = pending[0]
|
|
757
|
+
try:
|
|
758
|
+
run_node_workbench(["map", "interrupted", "--root", str(root), "--session", session,
|
|
759
|
+
"--event-id", item["id"], "--occurred-at", item["at"]])
|
|
760
|
+
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
|
|
761
|
+
# No adapter stderr or credentials enter development memory.
|
|
762
|
+
runtime["interrupt_sync"] = "pending"
|
|
763
|
+
else:
|
|
764
|
+
runtime["pending_interrupts"] = pending[1:]
|
|
765
|
+
runtime["interrupt_sync"] = "pending" if pending[1:] else "confirmed"
|
|
766
|
+
write_hook_runtime(root, session, runtime)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def tool_paths(payload: object, root: Path) -> list[str]:
|
|
770
|
+
if not isinstance(payload, dict):
|
|
771
|
+
return []
|
|
772
|
+
found: set[str] = set()
|
|
773
|
+
|
|
774
|
+
def add(value: object) -> None:
|
|
775
|
+
values = value if isinstance(value, list) else [value]
|
|
776
|
+
for item in values:
|
|
777
|
+
if not isinstance(item, str) or not item.strip():
|
|
778
|
+
continue
|
|
779
|
+
candidate = Path(item).expanduser()
|
|
780
|
+
try:
|
|
781
|
+
resolved = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve()
|
|
782
|
+
relative = resolved.relative_to(root.resolve())
|
|
783
|
+
except (OSError, ValueError):
|
|
784
|
+
continue
|
|
785
|
+
found.add(relative.as_posix())
|
|
786
|
+
|
|
787
|
+
def walk(value: object) -> None:
|
|
788
|
+
if isinstance(value, dict):
|
|
789
|
+
for key, child in value.items():
|
|
790
|
+
if key.lower() in {"path", "paths", "file", "file_path", "filepath", "target_file", "targetfile"}:
|
|
791
|
+
add(child)
|
|
792
|
+
walk(child)
|
|
793
|
+
elif isinstance(value, list):
|
|
794
|
+
for child in value:
|
|
795
|
+
walk(child)
|
|
796
|
+
|
|
797
|
+
walk(payload.get("tool_input", {}))
|
|
798
|
+
command = tool_command(payload)
|
|
799
|
+
for match in re.finditer(r"^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+)$", command, re.MULTILINE):
|
|
800
|
+
add(match.group(1).strip())
|
|
801
|
+
for target in shell_output_targets(command):
|
|
802
|
+
add(target)
|
|
803
|
+
return sorted(found)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def shell_output_targets(command: str) -> list[str]:
|
|
807
|
+
"""Find literal shell write targets without treating heredoc content as commands."""
|
|
808
|
+
first_line = command.split("\n", 1)[0]
|
|
809
|
+
if not first_line:
|
|
810
|
+
return []
|
|
811
|
+
try:
|
|
812
|
+
lexer = shlex.shlex(first_line, posix=os.name != "nt", punctuation_chars=";&|<>")
|
|
813
|
+
lexer.whitespace_split = True
|
|
814
|
+
lexer.commenters = ""
|
|
815
|
+
tokens = list(lexer)
|
|
816
|
+
except ValueError:
|
|
817
|
+
return []
|
|
818
|
+
targets: list[str] = []
|
|
819
|
+
for index, token in enumerate(tokens[:-1]):
|
|
820
|
+
if token in {">", ">>", ">|", "&>"}:
|
|
821
|
+
target = tokens[index + 1]
|
|
822
|
+
if target not in {"/dev/null", "&1", "&2"} and not target.isdigit():
|
|
823
|
+
targets.append(target)
|
|
824
|
+
segment: list[str] = []
|
|
825
|
+
for token in [*tokens, ";"]:
|
|
826
|
+
if token not in {";", "&&", "||", "|"}:
|
|
827
|
+
segment.append(token)
|
|
828
|
+
continue
|
|
829
|
+
words = segment
|
|
830
|
+
segment = []
|
|
831
|
+
if not words:
|
|
832
|
+
continue
|
|
833
|
+
command_name = Path(words[0]).name
|
|
834
|
+
args = words[1:]
|
|
835
|
+
if command_name == "git" and args and args[0] in {"mv", "rm"}:
|
|
836
|
+
command_name, args = args[0], args[1:]
|
|
837
|
+
if command_name in {"mv", "cp", "rm", "touch", "mkdir"}:
|
|
838
|
+
targets.extend(value for value in args if value != "--" and
|
|
839
|
+
not value.startswith("-") and value not in {">", ">>", ">|", "&>", "<", "<<"})
|
|
840
|
+
return targets
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def tool_target_strings(payload: object) -> list[str]:
|
|
844
|
+
"""Declared write targets, including host paths outside the project."""
|
|
845
|
+
if not isinstance(payload, dict):
|
|
846
|
+
return []
|
|
847
|
+
found: list[str] = []
|
|
848
|
+
|
|
849
|
+
def add(value: object) -> None:
|
|
850
|
+
values = value if isinstance(value, list) else [value]
|
|
851
|
+
for item in values:
|
|
852
|
+
if isinstance(item, str) and item.strip():
|
|
853
|
+
found.append(item.strip())
|
|
854
|
+
|
|
855
|
+
def walk(value: object) -> None:
|
|
856
|
+
if isinstance(value, dict):
|
|
857
|
+
for key, child in value.items():
|
|
858
|
+
if key.lower() in {"path", "paths", "file", "file_path", "filepath", "target_file", "targetfile"}:
|
|
859
|
+
add(child)
|
|
860
|
+
walk(child)
|
|
861
|
+
elif isinstance(value, list):
|
|
862
|
+
for child in value:
|
|
863
|
+
walk(child)
|
|
864
|
+
|
|
865
|
+
walk(payload.get("tool_input", {}))
|
|
866
|
+
command = tool_command(payload)
|
|
867
|
+
for match in re.finditer(r"^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+)$", command, re.MULTILINE):
|
|
868
|
+
add(match.group(1).strip())
|
|
869
|
+
for target in shell_output_targets(command):
|
|
870
|
+
add(target)
|
|
871
|
+
return found
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def write_like_tool(payload: object) -> bool:
|
|
875
|
+
if not isinstance(payload, dict):
|
|
876
|
+
return False
|
|
877
|
+
name = str(payload.get("tool_name") or payload.get("toolName") or "").lower()
|
|
878
|
+
return any(marker in name for marker in ("apply_patch", "write", "edit", "delete", "move"))
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _resolved_path(value: Path) -> Path | None:
|
|
882
|
+
try:
|
|
883
|
+
return value.expanduser().resolve()
|
|
884
|
+
except (OSError, RuntimeError, ValueError):
|
|
885
|
+
return None
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _under(path: Path, base: Path) -> bool:
|
|
889
|
+
try:
|
|
890
|
+
path.relative_to(base)
|
|
891
|
+
return True
|
|
892
|
+
except ValueError:
|
|
893
|
+
return False
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def temp_request_roots() -> list[Path]:
|
|
897
|
+
raw = [tempfile.gettempdir(), os.environ.get("TMPDIR"), os.environ.get("TEMP"), os.environ.get("TMP")]
|
|
898
|
+
if os.name != "nt":
|
|
899
|
+
raw.append("/tmp")
|
|
900
|
+
found: list[Path] = []
|
|
901
|
+
seen: set[str] = set()
|
|
902
|
+
for item in raw:
|
|
903
|
+
if not item:
|
|
904
|
+
continue
|
|
905
|
+
resolved = _resolved_path(Path(item))
|
|
906
|
+
if resolved is None:
|
|
907
|
+
continue
|
|
908
|
+
key = os.path.normcase(str(resolved))
|
|
909
|
+
if key in seen:
|
|
910
|
+
continue
|
|
911
|
+
seen.add(key)
|
|
912
|
+
found.append(resolved)
|
|
913
|
+
return found
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def temp_output_path(target: str) -> bool:
|
|
917
|
+
"""True when a shell redirect target is a host-temp capture file."""
|
|
918
|
+
candidate = _resolved_path(Path(target.strip("\"'")))
|
|
919
|
+
if candidate is None:
|
|
920
|
+
return False
|
|
921
|
+
return any(_under(candidate, temp) for temp in temp_request_roots())
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def protocol_request_write(payload: object, root: Path) -> bool:
|
|
925
|
+
"""Allow only host-temp JSON request files, never sibling trees or user hooks."""
|
|
926
|
+
targets = tool_target_strings(payload)
|
|
927
|
+
if not targets:
|
|
928
|
+
return False
|
|
929
|
+
root_resolved = _resolved_path(root)
|
|
930
|
+
temps = temp_request_roots()
|
|
931
|
+
if root_resolved is None or not temps:
|
|
932
|
+
return False
|
|
933
|
+
for item in targets:
|
|
934
|
+
candidate = Path(item).expanduser()
|
|
935
|
+
resolved = _resolved_path(candidate if candidate.is_absolute() else root / candidate)
|
|
936
|
+
if resolved is None or resolved.suffix.lower() != ".json":
|
|
937
|
+
return False
|
|
938
|
+
if _under(resolved, root_resolved):
|
|
939
|
+
return False
|
|
940
|
+
if any(part.lower() in {".claude", ".codex", ".cursor"} for part in resolved.parts):
|
|
941
|
+
return False
|
|
942
|
+
if not any(_under(resolved, temp) for temp in temps):
|
|
943
|
+
return False
|
|
944
|
+
return True
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def protocol_command(words: list[str], allowed: set[str]) -> bool:
|
|
948
|
+
"""Recognize a Context Guard entry point without trusting its absolute path."""
|
|
949
|
+
if not words:
|
|
950
|
+
return False
|
|
951
|
+
entrypoints = {"context-guard", "context-guard-skill", "context_guard.py", "context-guard-skill.js"}
|
|
952
|
+
index = 0
|
|
953
|
+
first = words[0].strip("\"'")
|
|
954
|
+
if Path(first).name in {"node", "node.exe", "python", "python3", "python.exe"}:
|
|
955
|
+
index = 1
|
|
956
|
+
entry = words[index].strip("\"'") if index < len(words) else ""
|
|
957
|
+
return index < len(words) and Path(entry).name in entrypoints and index + 1 < len(words) and words[index + 1] in allowed
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def control_words(words: list[str]) -> bool:
|
|
961
|
+
"""Protocol writes are their own audited recovery path, not product edits."""
|
|
962
|
+
return protocol_command(words, {
|
|
963
|
+
"plan-start", "plan-finish", "plan-status", "archive-session", "resolve-signal", "split-signal",
|
|
964
|
+
"record-todo", "record-bad-case", "record-bad-case-fix", "map", "sync", "workbench",
|
|
965
|
+
"preferences", "memory", "set-language", "write-candidates", "doctor",
|
|
966
|
+
})
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def diagnostic_words(words: list[str]) -> bool:
|
|
970
|
+
"""Agents often append a no-op status probe after an audited command."""
|
|
971
|
+
if not words:
|
|
972
|
+
return False
|
|
973
|
+
return Path(words[0]).name in {"echo", "true", "false", "printf"}
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def shell_segments(command: str) -> list[list[str]] | None:
|
|
977
|
+
"""Parse a conservative shell subset used for inspection pipelines."""
|
|
978
|
+
if not command.strip():
|
|
979
|
+
return []
|
|
980
|
+
if "\n" in command or "`" in command or "$(" in command:
|
|
981
|
+
return None
|
|
982
|
+
try:
|
|
983
|
+
# POSIX shlex consumes backslashes in an unquoted Windows path. Native
|
|
984
|
+
# shell syntax needs native token preservation so CLI paths remain valid.
|
|
985
|
+
lexer = shlex.shlex(command, posix=os.name != "nt", punctuation_chars=";&|<>")
|
|
986
|
+
lexer.whitespace_split = True
|
|
987
|
+
lexer.commenters = ""
|
|
988
|
+
tokens = list(lexer)
|
|
989
|
+
except ValueError:
|
|
990
|
+
return None
|
|
991
|
+
segments: list[list[str]] = []
|
|
992
|
+
current: list[str] = []
|
|
993
|
+
index = 0
|
|
994
|
+
while index < len(tokens):
|
|
995
|
+
token = tokens[index]
|
|
996
|
+
if token in {"|", "||", "&&", ";"}:
|
|
997
|
+
if not current:
|
|
998
|
+
return None
|
|
999
|
+
segments.append(current)
|
|
1000
|
+
current = []
|
|
1001
|
+
index += 1
|
|
1002
|
+
continue
|
|
1003
|
+
if token in {">", ">>", ">&", "&>", "<<", "<<<"}:
|
|
1004
|
+
# Discarding diagnostic noise is harmless; every other output target writes.
|
|
1005
|
+
if current and current[-1].isdigit():
|
|
1006
|
+
current.pop()
|
|
1007
|
+
target = tokens[index + 1] if index + 1 < len(tokens) else ""
|
|
1008
|
+
if token == ">" and target == "/dev/null":
|
|
1009
|
+
index += 2
|
|
1010
|
+
continue
|
|
1011
|
+
if token == ">&" and target.isdigit():
|
|
1012
|
+
index += 2
|
|
1013
|
+
continue
|
|
1014
|
+
if token == "&>" and target == "/dev/null":
|
|
1015
|
+
index += 2
|
|
1016
|
+
continue
|
|
1017
|
+
# Read-only inspection may capture CLI output in host temp without a plan.
|
|
1018
|
+
if token in {">", ">>"} and current and read_only_words(current) and temp_output_path(target):
|
|
1019
|
+
index += 2
|
|
1020
|
+
continue
|
|
1021
|
+
return None
|
|
1022
|
+
if token == "<":
|
|
1023
|
+
if current and current[-1].isdigit():
|
|
1024
|
+
current.pop()
|
|
1025
|
+
if index + 1 >= len(tokens) or tokens[index + 1] in {"|", "||", "&&", ";"}:
|
|
1026
|
+
return None
|
|
1027
|
+
index += 2
|
|
1028
|
+
continue
|
|
1029
|
+
if re.fullmatch(r"(?:\d*)>&\d+", token):
|
|
1030
|
+
index += 1
|
|
1031
|
+
continue
|
|
1032
|
+
if "&" in token:
|
|
1033
|
+
return None
|
|
1034
|
+
current.append(token)
|
|
1035
|
+
index += 1
|
|
1036
|
+
if current:
|
|
1037
|
+
segments.append(current)
|
|
1038
|
+
return segments if segments else None
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def git_read_only(words: list[str]) -> bool:
|
|
1042
|
+
if len(words) < 2:
|
|
1043
|
+
return False
|
|
1044
|
+
command_index = 1
|
|
1045
|
+
while command_index < len(words) and words[command_index] in {"--no-pager", "-C"}:
|
|
1046
|
+
if words[command_index] == "-C":
|
|
1047
|
+
command_index += 2
|
|
1048
|
+
else:
|
|
1049
|
+
command_index += 1
|
|
1050
|
+
if command_index >= len(words) or words[command_index].startswith("-"):
|
|
1051
|
+
return False
|
|
1052
|
+
command = words[command_index]
|
|
1053
|
+
arguments = words[command_index + 1:]
|
|
1054
|
+
if any(item in {"--output", "--ext-diff", "--textconv", "--open-files-in-pager"} or item.startswith("--output=") for item in arguments):
|
|
1055
|
+
return False
|
|
1056
|
+
if command in {"status", "diff", "log", "show", "ls-files", "rev-parse", "describe", "blame", "grep"}:
|
|
1057
|
+
return True
|
|
1058
|
+
if command == "worktree":
|
|
1059
|
+
return bool(arguments) and arguments[0] == "list"
|
|
1060
|
+
if command == "remote":
|
|
1061
|
+
return not arguments or arguments == ["-v"] or arguments == ["--verbose"]
|
|
1062
|
+
if command == "branch":
|
|
1063
|
+
mutating = {"-d", "-D", "-m", "-M", "-c", "-C", "-t", "-u", "--delete", "--move", "--copy", "--track", "--no-track", "--edit-description", "--set-upstream-to", "--unset-upstream"}
|
|
1064
|
+
if any(item in mutating or any(item.startswith(flag + "=") for flag in mutating if flag.startswith("--")) for item in arguments):
|
|
1065
|
+
return False
|
|
1066
|
+
return not arguments or any(item in {"--show-current", "--list", "-a", "--all", "-r", "--remotes", "-v", "-vv", "--verbose"} for item in arguments)
|
|
1067
|
+
return False
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def curl_read_only(words: list[str]) -> bool:
|
|
1071
|
+
forbidden = {
|
|
1072
|
+
"-d", "--data", "--data-ascii", "--data-binary", "--data-raw", "--data-urlencode",
|
|
1073
|
+
"-F", "--form", "--form-string", "-T", "--upload-file", "-o", "--output", "-O",
|
|
1074
|
+
"--remote-name", "--remote-header-name", "-K", "--config", "--remove-on-error",
|
|
1075
|
+
}
|
|
1076
|
+
index = 1
|
|
1077
|
+
while index < len(words):
|
|
1078
|
+
item = words[index]
|
|
1079
|
+
if item in forbidden or any(item.startswith(flag + "=") for flag in forbidden if flag.startswith("--")):
|
|
1080
|
+
return False
|
|
1081
|
+
if item.startswith("-") and len(item) > 2 and not item.startswith("--") and any(flag in item[1:] for flag in "dFToOK"):
|
|
1082
|
+
return False
|
|
1083
|
+
if item in {"-X", "--request"}:
|
|
1084
|
+
if index + 1 >= len(words) or words[index + 1].upper() not in {"GET", "HEAD"}:
|
|
1085
|
+
return False
|
|
1086
|
+
index += 1
|
|
1087
|
+
elif item.startswith("-X") and len(item) > 2 and item[2:].upper() not in {"GET", "HEAD"}:
|
|
1088
|
+
return False
|
|
1089
|
+
elif item.startswith("--request=") and item.split("=", 1)[1].upper() not in {"GET", "HEAD"}:
|
|
1090
|
+
return False
|
|
1091
|
+
index += 1
|
|
1092
|
+
return True
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
INTERPRETER_EXECUTABLES = {"python", "python3", "python.exe", "node", "node.exe"}
|
|
1096
|
+
READONLY_MAP_ACTIONS = {"status", "read", "changes", "inbox", "watch", "execution", "operation"}
|
|
1097
|
+
INSPECTION_PROTOCOL_COMMANDS = {
|
|
1098
|
+
"plan-status", "doctor", "preferences", "set-language", "write-candidates",
|
|
1099
|
+
}
|
|
1100
|
+
INSPECTION_SYNC_ACTIONS = {"status", "pull"}
|
|
1101
|
+
INSPECTION_MEMORY_ACTIONS = {"status", "history"}
|
|
1102
|
+
MUTATING_PROTOCOL_COMMANDS = {
|
|
1103
|
+
"plan-start", "plan-finish", "archive-session", "resolve-signal", "split-signal",
|
|
1104
|
+
"record-todo", "record-bad-case", "record-bad-case-fix",
|
|
1105
|
+
}
|
|
1106
|
+
PYTHON_C_FORBIDDEN = (
|
|
1107
|
+
"open(", "write(", "os.", "subprocess", "shutil", "pathlib", "import os", "exec(", "eval(", "__import__",
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def protocol_entry_index(words: list[str]) -> int | None:
|
|
1112
|
+
if not words:
|
|
1113
|
+
return None
|
|
1114
|
+
index = 0
|
|
1115
|
+
first = words[0].strip("\"'")
|
|
1116
|
+
if Path(first).name in INTERPRETER_EXECUTABLES:
|
|
1117
|
+
index = 1
|
|
1118
|
+
if index >= len(words):
|
|
1119
|
+
return None
|
|
1120
|
+
entry = Path(words[index].strip("\"'")).name
|
|
1121
|
+
if entry not in {"context-guard", "context-guard-skill", "context_guard.py", "context-guard-skill.js"}:
|
|
1122
|
+
return None
|
|
1123
|
+
return index
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def inspection_protocol_words(words: list[str]) -> bool:
|
|
1127
|
+
"""Read-only Context Guard CLI output safe to pipe into inspection tools."""
|
|
1128
|
+
index = protocol_entry_index(words)
|
|
1129
|
+
if index is None or index + 1 >= len(words):
|
|
1130
|
+
return False
|
|
1131
|
+
command = words[index + 1]
|
|
1132
|
+
if command in MUTATING_PROTOCOL_COMMANDS:
|
|
1133
|
+
return False
|
|
1134
|
+
if command == "map":
|
|
1135
|
+
if index + 2 >= len(words):
|
|
1136
|
+
return False
|
|
1137
|
+
action = words[index + 2]
|
|
1138
|
+
if action == "ci":
|
|
1139
|
+
return index + 3 < len(words) and words[index + 3] == "context"
|
|
1140
|
+
if action == "task":
|
|
1141
|
+
return False
|
|
1142
|
+
return action in READONLY_MAP_ACTIONS
|
|
1143
|
+
if command == "sync":
|
|
1144
|
+
return index + 2 < len(words) and words[index + 2] in INSPECTION_SYNC_ACTIONS
|
|
1145
|
+
if command == "memory":
|
|
1146
|
+
return index + 2 < len(words) and words[index + 2] in INSPECTION_MEMORY_ACTIONS
|
|
1147
|
+
if command == "workbench":
|
|
1148
|
+
return any(flag in words for flag in ("--diagnose", "--binding-status", "--list"))
|
|
1149
|
+
return command in INSPECTION_PROTOCOL_COMMANDS
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def node_eval_read_only(script: str) -> bool:
|
|
1153
|
+
stripped = script.strip()
|
|
1154
|
+
# Only a literal diagnostic is safe; concatenation can evaluate arbitrary code.
|
|
1155
|
+
return bool(re.fullmatch(r"""console\.log\((?:'[^'\\\r\n]*'|"[^"\\\r\n]*")\)\s*;?""", stripped))
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def python_c_read_only(script: str) -> bool:
|
|
1159
|
+
if len(script) > 500 or any(token in script for token in PYTHON_C_FORBIDDEN):
|
|
1160
|
+
return False
|
|
1161
|
+
return True
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def interpreter_read_only(words: list[str]) -> bool:
|
|
1165
|
+
if not words:
|
|
1166
|
+
return False
|
|
1167
|
+
executable = Path(words[0]).name
|
|
1168
|
+
if executable not in INTERPRETER_EXECUTABLES:
|
|
1169
|
+
return False
|
|
1170
|
+
args = words[1:]
|
|
1171
|
+
if not args:
|
|
1172
|
+
return False
|
|
1173
|
+
if args in (["--version"], ["-V"], ["-v"], ["--version"]):
|
|
1174
|
+
return True
|
|
1175
|
+
if executable.startswith("node") and len(args) == 2 and args[0] == "-e":
|
|
1176
|
+
script = args[1]
|
|
1177
|
+
# Non-POSIX shlex retains the shell quoting used on Windows. Unwrap only
|
|
1178
|
+
# the complete script argument, not paths or arbitrary command segments.
|
|
1179
|
+
if os.name == "nt" and len(script) >= 2 and script[0] == script[-1] and script[0] in {"'", '"'}:
|
|
1180
|
+
script = script[1:-1]
|
|
1181
|
+
return node_eval_read_only(script)
|
|
1182
|
+
if len(args) >= 2 and args[0] == "-m" and args[1] == "json.tool":
|
|
1183
|
+
return True
|
|
1184
|
+
if args[0] == "-c" and len(args) >= 2:
|
|
1185
|
+
return python_c_read_only(" ".join(args[1:]))
|
|
1186
|
+
return False
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def bare_interpreter_pipe_sink(words: list[str]) -> bool:
|
|
1190
|
+
return len(words) == 1 and Path(words[0]).name in INTERPRETER_EXECUTABLES
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def read_only_words(words: list[str]) -> bool:
|
|
1194
|
+
if not words:
|
|
1195
|
+
return False
|
|
1196
|
+
if control_words(words):
|
|
1197
|
+
return True
|
|
1198
|
+
if protocol_command(words, {"workbench"}):
|
|
1199
|
+
return "--diagnose" in words or "--binding-status" in words
|
|
1200
|
+
if interpreter_read_only(words):
|
|
1201
|
+
return True
|
|
1202
|
+
executable = Path(words[0]).name
|
|
1203
|
+
if executable in {"pwd", "ls", "cat", "head", "tail", "grep", "stat", "wc", "which", "type", "dirname", "basename", "realpath", "readlink", "printf", "echo", "true", "false"}:
|
|
1204
|
+
return True
|
|
1205
|
+
if executable == "rg":
|
|
1206
|
+
return "--pre" not in words and not any(item.startswith("--pre=") for item in words)
|
|
1207
|
+
if executable == "sed":
|
|
1208
|
+
return not any(item.startswith("--in-place") or re.match(r"^-[^-]*i", item) for item in words[1:])
|
|
1209
|
+
if executable == "find":
|
|
1210
|
+
dangerous = {"-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls"}
|
|
1211
|
+
return not any(item in dangerous for item in words[1:])
|
|
1212
|
+
if executable == "git":
|
|
1213
|
+
return git_read_only(words)
|
|
1214
|
+
if executable == "gh":
|
|
1215
|
+
return len(words) >= 3 and (words[1], words[2]) in {
|
|
1216
|
+
("auth", "status"), ("pr", "checks"), ("pr", "list"),
|
|
1217
|
+
("pr", "view"), ("run", "view"), ("run", "list"),
|
|
1218
|
+
}
|
|
1219
|
+
if executable in {"ps", "pgrep", "lsof", "netstat", "ss"}:
|
|
1220
|
+
return True
|
|
1221
|
+
if executable == "kill":
|
|
1222
|
+
return len(words) >= 3 and words[1] == "-0"
|
|
1223
|
+
if executable in {"nc", "netcat"}:
|
|
1224
|
+
return any(item == "-z" or (item.startswith("-") and "z" in item[1:]) for item in words[1:])
|
|
1225
|
+
if executable == "curl":
|
|
1226
|
+
return curl_read_only(words)
|
|
1227
|
+
return False
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def read_only_shell(command: str) -> bool:
|
|
1231
|
+
segments = shell_segments(command)
|
|
1232
|
+
if segments is None:
|
|
1233
|
+
return False
|
|
1234
|
+
for index, segment in enumerate(segments):
|
|
1235
|
+
if index > 0 and bare_interpreter_pipe_sink(segment) and inspection_protocol_words(segments[index - 1]):
|
|
1236
|
+
continue
|
|
1237
|
+
if not read_only_words(segment):
|
|
1238
|
+
return False
|
|
1239
|
+
return True
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def ci_tool_allowed(payload: object, execution: dict):
|
|
1243
|
+
active = execution.get("active") or {}
|
|
1244
|
+
if active.get("mode") != "ci" and not execution.get("ci"):
|
|
1245
|
+
return None
|
|
1246
|
+
return active.get("mode") == "ci" and tool_command(payload).strip() in (active.get("commands") or [])
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def control_tool(payload: object) -> bool:
|
|
1250
|
+
"""Only standalone protocol commands can recover a blocked lifecycle."""
|
|
1251
|
+
segments = shell_segments(tool_command(payload))
|
|
1252
|
+
if not segments:
|
|
1253
|
+
return False
|
|
1254
|
+
# Drop trailing diagnostic probes (`echo EXIT=$?`) after an audited command.
|
|
1255
|
+
while len(segments) > 1 and diagnostic_words(segments[-1]) and not control_words(segments[-1]):
|
|
1256
|
+
segments = segments[:-1]
|
|
1257
|
+
if not control_words(segments[-1]):
|
|
1258
|
+
return False
|
|
1259
|
+
# Allow a literal stdin producer before an audited Context Guard command.
|
|
1260
|
+
# Arbitrary programs and additional commands never inherit this exemption.
|
|
1261
|
+
return len(segments) == 1 or (
|
|
1262
|
+
len(segments) == 2
|
|
1263
|
+
and Path(segments[0][0]).name in {"printf", "echo"}
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def git_changed_paths(root: Path) -> list[str]:
|
|
1268
|
+
try:
|
|
1269
|
+
result = subprocess.run(
|
|
1270
|
+
["git", "status", "--porcelain", "--untracked-files=all", "-z"], cwd=root, capture_output=True,
|
|
1271
|
+
timeout=5, check=False, creationflags=WINDOWS_NO_WINDOW,
|
|
1272
|
+
)
|
|
1273
|
+
except (OSError, subprocess.SubprocessError):
|
|
1274
|
+
return []
|
|
1275
|
+
if result.returncode:
|
|
1276
|
+
return []
|
|
1277
|
+
paths: set[str] = set()
|
|
1278
|
+
for entry in result.stdout.decode("utf-8", errors="replace").split("\0"):
|
|
1279
|
+
if len(entry) < 4:
|
|
1280
|
+
continue
|
|
1281
|
+
value = entry[3:]
|
|
1282
|
+
if " -> " in value:
|
|
1283
|
+
value = value.split(" -> ", 1)[1]
|
|
1284
|
+
if value and not value.startswith(".codex/context/private/"):
|
|
1285
|
+
paths.add(value)
|
|
1286
|
+
return sorted(paths)
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def mutating_tool(payload: object) -> bool:
|
|
1290
|
+
if not isinstance(payload, dict):
|
|
1291
|
+
return False
|
|
1292
|
+
if write_like_tool(payload):
|
|
1293
|
+
return True
|
|
1294
|
+
name = str(payload.get("tool_name") or payload.get("toolName") or "").lower()
|
|
1295
|
+
if name not in {"bash", "exec_command", "shell", "run_shell_command"}:
|
|
1296
|
+
return False
|
|
1297
|
+
if control_tool(payload):
|
|
1298
|
+
return False
|
|
1299
|
+
command = tool_command(payload).strip()
|
|
1300
|
+
# Unknown scripts stay potentially mutating. Inspection pipelines are allowed
|
|
1301
|
+
# only when every command and redirection is independently read-only.
|
|
1302
|
+
return not read_only_shell(command)
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def post_plan_delivery_command(payload: object, root: Path) -> bool:
|
|
1306
|
+
"""Allow only repository delivery after reviewed work is archived and finished."""
|
|
1307
|
+
command = tool_command(payload)
|
|
1308
|
+
segments = shell_segments(command)
|
|
1309
|
+
if not segments or len(segments) != 1:
|
|
1310
|
+
return False
|
|
1311
|
+
words = segments[0]
|
|
1312
|
+
if not words or any(char in command for char in ("$", "`", "\n")):
|
|
1313
|
+
return False
|
|
1314
|
+
executable = Path(words[0]).name
|
|
1315
|
+
if executable == "git":
|
|
1316
|
+
index = 1
|
|
1317
|
+
if index < len(words) and words[index] == "-C":
|
|
1318
|
+
if index + 1 >= len(words) or _resolved_path(Path(words[index + 1])) != root.resolve():
|
|
1319
|
+
return False
|
|
1320
|
+
index += 2
|
|
1321
|
+
if index < len(words) and words[index] == "push":
|
|
1322
|
+
return not any(arg in {"--force", "-f", "--force-with-lease", "--mirror", "--delete"}
|
|
1323
|
+
or arg.startswith("--force-") for arg in words[index + 1:])
|
|
1324
|
+
return False
|
|
1325
|
+
if executable == "gh" and len(words) >= 3 and words[1] == "pr":
|
|
1326
|
+
if words[2] not in {"create", "merge"}:
|
|
1327
|
+
return False
|
|
1328
|
+
if words[2] == "merge" and (len(words) < 4 or not re.fullmatch(r"[1-9]\d*", words[3])):
|
|
1329
|
+
return False
|
|
1330
|
+
return not any(arg in {"--admin", "--repo", "-R", "--delete-branch"}
|
|
1331
|
+
or arg.startswith(("--repo=", "--admin=")) for arg in words[3:])
|
|
1332
|
+
return False
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
def gh_pr_merge_command(payload: object) -> bool:
|
|
1336
|
+
"""Recognize PR merges even when wrapped in shell chaining or redirection."""
|
|
1337
|
+
command = tool_command(payload)
|
|
1338
|
+
segments = shell_segments(command)
|
|
1339
|
+
if segments and any(len(words) >= 3 and Path(words[0]).name == "gh" and words[1:3] == ["pr", "merge"]
|
|
1340
|
+
for words in segments):
|
|
1341
|
+
return True
|
|
1342
|
+
return bool(re.search(r"\bgh\s+pr\s+merge\b", command))
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def post_plan_merge_target(payload: object) -> str | None:
|
|
1346
|
+
segments = shell_segments(tool_command(payload))
|
|
1347
|
+
words = segments[0] if segments and len(segments) == 1 else []
|
|
1348
|
+
return words[3] if len(words) >= 4 and Path(words[0]).name == "gh" and words[1:3] == ["pr", "merge"] else None
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
def merge_checks_green(pr: dict, source_sha: str) -> bool:
|
|
1352
|
+
checks = pr.get("statusCheckRollup")
|
|
1353
|
+
if (pr.get("state") != "OPEN" or pr.get("mergeStateStatus") != "CLEAN" or
|
|
1354
|
+
pr.get("headRefOid") != source_sha or not isinstance(checks, list) or not checks or len(checks) > 100):
|
|
1355
|
+
return False
|
|
1356
|
+
def passed(check: dict) -> bool:
|
|
1357
|
+
if check.get("__typename") == "CheckRun":
|
|
1358
|
+
return check.get("status") == "COMPLETED" and check.get("conclusion") in {"SUCCESS", "SKIPPED"}
|
|
1359
|
+
return check.get("__typename") == "StatusContext" and check.get("state") == "SUCCESS"
|
|
1360
|
+
return all(isinstance(check, dict) and passed(check) for check in checks) and any(
|
|
1361
|
+
check.get("conclusion") == "SUCCESS" or check.get("state") == "SUCCESS" for check in checks)
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def billing_waiver_checks(root: Path, pr: dict, source_sha: str) -> bool:
|
|
1365
|
+
"""An expiring, repository-scoped waiver for GitHub jobs blocked before startup."""
|
|
1366
|
+
repository = os.environ.get("CONTEXT_GUARD_GITHUB_BILLING_WAIVER_REPO", "")
|
|
1367
|
+
expires_at = os.environ.get("CONTEXT_GUARD_GITHUB_BILLING_WAIVER_UNTIL", "")
|
|
1368
|
+
try:
|
|
1369
|
+
deadline = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
|
1370
|
+
if not re.fullmatch(r"[\w.-]+/[\w.-]+", repository) or deadline.tzinfo is None or datetime.now(timezone.utc) >= deadline:
|
|
1371
|
+
return False
|
|
1372
|
+
if pr.get("state") != "OPEN" or pr.get("mergeStateStatus") != "UNSTABLE" or pr.get("headRefOid") != source_sha:
|
|
1373
|
+
return False
|
|
1374
|
+
rollup = pr.get("statusCheckRollup")
|
|
1375
|
+
if not isinstance(rollup, list) or not rollup or any(not isinstance(item, dict) or
|
|
1376
|
+
(item.get("__typename") == "StatusContext" and item.get("state") != "SUCCESS") or
|
|
1377
|
+
(item.get("__typename") == "CheckRun" and (item.get("status") != "COMPLETED" or
|
|
1378
|
+
item.get("conclusion") not in {"SUCCESS", "SKIPPED", "FAILURE"})) or
|
|
1379
|
+
item.get("__typename") not in {"StatusContext", "CheckRun"} for item in rollup):
|
|
1380
|
+
return False
|
|
1381
|
+
repo_result = subprocess.run(["gh", "repo", "view", "--json", "nameWithOwner"], cwd=root,
|
|
1382
|
+
capture_output=True, text=True, timeout=15, check=False, creationflags=WINDOWS_NO_WINDOW)
|
|
1383
|
+
if repo_result.returncode or json.loads(repo_result.stdout).get("nameWithOwner") != repository:
|
|
1384
|
+
return False
|
|
1385
|
+
runs_result = subprocess.run(["gh", "api", f"repos/{repository}/commits/{source_sha}/check-runs?filter=latest&per_page=100"],
|
|
1386
|
+
cwd=root, capture_output=True, text=True, timeout=15, check=False, creationflags=WINDOWS_NO_WINDOW)
|
|
1387
|
+
if runs_result.returncode:
|
|
1388
|
+
return False
|
|
1389
|
+
result = json.loads(runs_result.stdout)
|
|
1390
|
+
checks = result.get("check_runs")
|
|
1391
|
+
if not isinstance(checks, list) or not checks or len(checks) > 100 or result.get("total_count") != len(checks):
|
|
1392
|
+
return False
|
|
1393
|
+
failed = [check for check in checks if check.get("conclusion") == "failure"]
|
|
1394
|
+
if not failed or any(not isinstance(check, dict) or check.get("head_sha") != source_sha or check.get("status") != "completed" or
|
|
1395
|
+
check.get("conclusion") not in {"success", "skipped", "failure"} for check in checks):
|
|
1396
|
+
return False
|
|
1397
|
+
for check in failed:
|
|
1398
|
+
if not isinstance(check.get("id"), int) or check["id"] <= 0:
|
|
1399
|
+
return False
|
|
1400
|
+
annotations = subprocess.run(["gh", "api", f"repos/{repository}/check-runs/{check['id']}/annotations?per_page=100"],
|
|
1401
|
+
cwd=root, capture_output=True, text=True, timeout=15, check=False,
|
|
1402
|
+
creationflags=WINDOWS_NO_WINDOW)
|
|
1403
|
+
if annotations.returncode or not any(isinstance(item, dict) and
|
|
1404
|
+
"The job was not started because recent account payments have failed or your spending limit needs to be increased." in item.get("message", "")
|
|
1405
|
+
for item in json.loads(annotations.stdout)):
|
|
1406
|
+
return False
|
|
1407
|
+
return True
|
|
1408
|
+
except (OSError, ValueError, TypeError, subprocess.SubprocessError):
|
|
1409
|
+
return False
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
def verified_merge_checks(root: Path, number: str) -> bool:
|
|
1413
|
+
try:
|
|
1414
|
+
head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True,
|
|
1415
|
+
timeout=5, check=False, creationflags=WINDOWS_NO_WINDOW)
|
|
1416
|
+
pr = subprocess.run(["gh", "pr", "view", number, "--json", "state,mergeStateStatus,statusCheckRollup,headRefOid"],
|
|
1417
|
+
cwd=root, capture_output=True, text=True, timeout=15, check=False,
|
|
1418
|
+
creationflags=WINDOWS_NO_WINDOW)
|
|
1419
|
+
if head.returncode or pr.returncode:
|
|
1420
|
+
return False
|
|
1421
|
+
details = json.loads(pr.stdout)
|
|
1422
|
+
return merge_checks_green(details, head.stdout.strip()) or billing_waiver_checks(root, details, head.stdout.strip())
|
|
1423
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
1424
|
+
return False
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
def post_plan_delivery_ready(runtime: dict, execution: dict, root: Path) -> bool:
|
|
1428
|
+
last = runtime.get("last_plan") if isinstance(runtime.get("last_plan"), dict) else {}
|
|
1429
|
+
active = execution.get("active") or {}
|
|
1430
|
+
acceptance = active.get("acceptanceReview") or {}
|
|
1431
|
+
return (active.get("mode") == "reviewed"
|
|
1432
|
+
and acceptance.get("decision") == "approved"
|
|
1433
|
+
and last.get("status") == "completed"
|
|
1434
|
+
and isinstance(last.get("archive"), dict)
|
|
1435
|
+
and not git_changed_paths(root))
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
def pending_signals(runtime: dict) -> list[str]:
|
|
1439
|
+
return [str(item.get("id")) for item in runtime.get("signals", [])
|
|
1440
|
+
if isinstance(item, dict) and item.get("status") == "pending"]
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
def pending_signal_guidance(root: Path, session_id: str, runtime: dict) -> str:
|
|
1444
|
+
ids = pending_signals(runtime)
|
|
1445
|
+
command = (f"{context_guard_cli()} resolve-signal --root {json.dumps(str(root))} "
|
|
1446
|
+
f"--session {json.dumps(session_id)} --signal {ids[0]} --kind task")
|
|
1447
|
+
return ("Classify pending user signals before implementation: " + ", ".join(ids)
|
|
1448
|
+
+ ". If this is the already-mounted Cloud task, run " + command
|
|
1449
|
+
+ ". For a new request, record the appropriate TODO or Bug instead. Do not try another write tool.")
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
def scope_paths(root: Path, values: object) -> list[str]:
|
|
1453
|
+
if not isinstance(values, list) or not values:
|
|
1454
|
+
raise ValueError("plan needs non-empty paths")
|
|
1455
|
+
result = []
|
|
1456
|
+
for value in values:
|
|
1457
|
+
if not isinstance(value, str) or not value.strip():
|
|
1458
|
+
raise ValueError("scope paths must be strings")
|
|
1459
|
+
candidate = Path(value)
|
|
1460
|
+
if candidate.is_absolute() or ".." in candidate.parts or value in {".", "./"}:
|
|
1461
|
+
raise ValueError("scope paths must be specific repository-relative files/directories")
|
|
1462
|
+
(root / candidate).resolve().relative_to(root.resolve())
|
|
1463
|
+
result.append(candidate.as_posix().rstrip("/") + ("/" if value.endswith("/") else ""))
|
|
1464
|
+
return sorted(set(result))
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
def in_scope(file: str, paths: list[str]) -> bool:
|
|
1468
|
+
return any(file == item.rstrip("/") or file.startswith(item.rstrip("/") + "/") for item in paths)
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
def scope_snapshot(root: Path, paths: list[str]) -> dict[str, str]:
|
|
1472
|
+
"""Content hashes, not dirty filenames: edits to already dirty files count."""
|
|
1473
|
+
result = {}
|
|
1474
|
+
for value in paths:
|
|
1475
|
+
target = root / value
|
|
1476
|
+
files = target.rglob("*") if target.is_dir() else [target]
|
|
1477
|
+
for file in files:
|
|
1478
|
+
relative = file.relative_to(root).as_posix()
|
|
1479
|
+
if any(part in {".git", ".codex", "node_modules", "__pycache__"} for part in file.relative_to(root).parts):
|
|
1480
|
+
continue
|
|
1481
|
+
if file.is_symlink():
|
|
1482
|
+
result[relative] = "link:" + os.readlink(file)
|
|
1483
|
+
elif file.is_file():
|
|
1484
|
+
result[relative] = hashlib.sha256(file.read_bytes()).hexdigest()
|
|
1485
|
+
return result
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
def checked_sync(root: Path, session: str, action: str, paths: list[str] | None = None) -> dict:
|
|
1489
|
+
value = sync_command(root, action, session, paths)
|
|
1490
|
+
if value.get("error") or value.get("status") == "conflict":
|
|
1491
|
+
raise ValueError(f"Cloud {action} failed: {json.dumps(value, ensure_ascii=False)}")
|
|
1492
|
+
return value
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
def prepare_plan_sync(root: Path, session: str, paths: list[str]) -> dict:
|
|
1496
|
+
# The caller has already verified live node grants, reads and the inbox.
|
|
1497
|
+
# A transport outage is not a code-delivery verdict. Conflicts and denied
|
|
1498
|
+
# authority remain blocking; no local baseline is fabricated or replaced.
|
|
1499
|
+
value = sync_command(root, "prepare", session, paths)
|
|
1500
|
+
code = (value.get("error") or {}).get("code")
|
|
1501
|
+
if value.get("status") != "conflict" and code in {"MEMORY_UNAVAILABLE", "SYNC_TOOL_FAILED", "HTTP_ERROR", "UNAVAILABLE"}:
|
|
1502
|
+
return {"pending": True, "code": code}
|
|
1503
|
+
if value.get("error") or value.get("status") == "conflict":
|
|
1504
|
+
raise ValueError("Cloud prepare requires reconciliation or authorization")
|
|
1505
|
+
return value
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
def plan_command(root: Path, session: str, command: str, data: dict) -> dict:
|
|
1509
|
+
with hook_runtime_lock(root, session):
|
|
1510
|
+
return _plan_command_locked(root, session, command, data)
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
def _plan_command_locked(root: Path, session: str, command: str, data: dict) -> dict:
|
|
1514
|
+
runtime = read_hook_runtime(root, session)
|
|
1515
|
+
if command == "plan-status":
|
|
1516
|
+
return {"active_plan": runtime.get("active_plan"), "last_plan": runtime.get("last_plan"), "pending_signals": pending_signals(runtime)}
|
|
1517
|
+
if session not in {str(item.get("session_id")) for item in session_records(root)}:
|
|
1518
|
+
raise ValueError("plan needs an actual lifecycle session")
|
|
1519
|
+
if pending_signals(runtime):
|
|
1520
|
+
raise ValueError(pending_signal_guidance(root, session, runtime))
|
|
1521
|
+
ctx = context_folder(root)
|
|
1522
|
+
plan = runtime.get("active_plan")
|
|
1523
|
+
if command == "plan-start":
|
|
1524
|
+
if plan and data.get("extend") is not True:
|
|
1525
|
+
raise ValueError("A plan is already active; finish it before opening another")
|
|
1526
|
+
if data.get("approved") is not True or not str(data.get("summary", "")).strip():
|
|
1527
|
+
raise ValueError("plan-start needs approved:true and summary; an explicit implementation request already satisfies approval")
|
|
1528
|
+
paths = scope_paths(root, data.get("paths"))
|
|
1529
|
+
nodes = data.get("node_ids")
|
|
1530
|
+
if not isinstance(nodes, list) or not nodes or not all(isinstance(item, str) for item in nodes):
|
|
1531
|
+
raise ValueError("plan-start needs node_ids")
|
|
1532
|
+
# A Cloud-reviewed task needs the actual Coordinator receipt, not the
|
|
1533
|
+
# developer's approved:true attestation. Ordinary manual plans retain
|
|
1534
|
+
# their existing explicit-user-approval contract.
|
|
1535
|
+
execution = run_node_workbench(["map", "execution", "--root", str(root), "--session", session])
|
|
1536
|
+
active = execution.get("active") or {}
|
|
1537
|
+
if active.get("mode") == "ci" or execution.get("ci"):
|
|
1538
|
+
raise ValueError("CI uses its assigned test authorization; it cannot start a development Plan")
|
|
1539
|
+
if active.get("mode") == "reviewed":
|
|
1540
|
+
reviewed = execution.get("plan") or {}
|
|
1541
|
+
approved = active.get("plan") or {}
|
|
1542
|
+
if not active.get("approval") or reviewed.get("kind") != "plan" or reviewed.get("version") != approved.get("version"):
|
|
1543
|
+
raise ValueError("Coordinator must approve this exact Cloud Plan before development")
|
|
1544
|
+
if data.get("taskId") != active.get("taskId") or data.get("planRef") != approved.get("ref") or data.get("planVersion") != approved.get("version"):
|
|
1545
|
+
raise ValueError("plan-start must identify the approved taskId, planRef and planVersion")
|
|
1546
|
+
allowed_paths = scope_paths(root, (reviewed.get("content") or {}).get("paths"))
|
|
1547
|
+
if set(nodes) - set(active.get("nodeIds") or []) or any(not any(value == allowed or value.startswith(allowed.rstrip("/") + "/") for allowed in allowed_paths) for value in paths):
|
|
1548
|
+
raise ValueError("Local plan scope exceeds the approved Cloud Plan")
|
|
1549
|
+
if plan:
|
|
1550
|
+
raise ValueError("Finish the previous local plan before starting a Cloud-reviewed Plan")
|
|
1551
|
+
head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True, timeout=5, check=False, creationflags=WINDOWS_NO_WINDOW)
|
|
1552
|
+
if head.returncode or head.stdout.strip() != approved.get("sourceSha"):
|
|
1553
|
+
raise ValueError("Worktree HEAD differs from the approved Plan base SHA")
|
|
1554
|
+
# Fresh API read checks page drafts and actual session authorization.
|
|
1555
|
+
state = run_node_workbench(["map", "status", "--root", str(root), "--session", session])
|
|
1556
|
+
missing = set(nodes) - set(state.get("grants") or [])
|
|
1557
|
+
if missing:
|
|
1558
|
+
raise ValueError("Map authorization required: " + ", ".join(sorted(missing)))
|
|
1559
|
+
for node in nodes:
|
|
1560
|
+
run_node_workbench(["map", "read", "--root", str(root), "--session", session, "--node", node])
|
|
1561
|
+
inbox = run_node_workbench(["map", "inbox", "--root", str(root), "--session", session, "--start"])
|
|
1562
|
+
if inbox.get("pending"):
|
|
1563
|
+
raise ValueError("Read/process and acknowledge Map inbox before starting the plan")
|
|
1564
|
+
if plan:
|
|
1565
|
+
old_paths = plan["paths"]
|
|
1566
|
+
baseline = scope_snapshot(root, paths)
|
|
1567
|
+
dirty_paths = set(git_changed_paths(root))
|
|
1568
|
+
# Keep the original baseline inside the old scope: an extension
|
|
1569
|
+
# must not hide earlier edits or complete pending acceptance.
|
|
1570
|
+
for file, digest in baseline.items():
|
|
1571
|
+
if not any(file == value or file.startswith(value.rstrip("/") + "/") for value in old_paths):
|
|
1572
|
+
if file in dirty_paths:
|
|
1573
|
+
prior = subprocess.run(["git", "show", "HEAD:" + file], cwd=root, capture_output=True, timeout=5, check=False, creationflags=WINDOWS_NO_WINDOW)
|
|
1574
|
+
plan["scope_review_required"] = True
|
|
1575
|
+
if prior.returncode:
|
|
1576
|
+
continue
|
|
1577
|
+
digest = hashlib.sha256(prior.stdout).hexdigest()
|
|
1578
|
+
plan["baseline"].setdefault(file, digest)
|
|
1579
|
+
plan["paths"] = sorted(set(old_paths + paths))
|
|
1580
|
+
plan["node_ids"] = sorted(set(plan["node_ids"] + nodes))
|
|
1581
|
+
if sync_configured(root, session):
|
|
1582
|
+
checked_sync(root, session, "checkpoint")
|
|
1583
|
+
plan.setdefault("amendments", []).append({"at": utc_now(), "summary": data["summary"], "paths": paths, "node_ids": nodes})
|
|
1584
|
+
plan["revision"] += 1
|
|
1585
|
+
plan.pop("archive", None)
|
|
1586
|
+
write_hook_runtime(root, session, runtime)
|
|
1587
|
+
append_session_event(root, "plan-extend", "cli", session, {"plan_id": plan["id"], "occurred_at": utc_now()})
|
|
1588
|
+
return plan
|
|
1589
|
+
baseline = scope_snapshot(root, paths)
|
|
1590
|
+
sync = prepare_plan_sync(root, session, paths) if sync_configured(root, session) else {}
|
|
1591
|
+
plan = {"id": "plan-" + hashlib.sha256(f"{session}:{utc_now()}".encode()).hexdigest()[:20],
|
|
1592
|
+
"summary": data["summary"], "status": "working", "started_at": utc_now(),
|
|
1593
|
+
"node_ids": sorted(set(nodes)), "paths": paths, "actual_paths": [],
|
|
1594
|
+
"baseline": baseline, "revision": 0, "map_version": state.get("version"), "sync": sync,
|
|
1595
|
+
"delivery": {"source": "working", "memory": "pending" if sync.get("pending") else "ready"}}
|
|
1596
|
+
runtime["active_plan"] = plan
|
|
221
1597
|
else:
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
1598
|
+
if not isinstance(plan, dict):
|
|
1599
|
+
if isinstance(runtime.get("last_plan"), dict) and runtime["last_plan"].get("status") == "completed":
|
|
1600
|
+
return runtime["last_plan"]
|
|
1601
|
+
raise ValueError("No active plan")
|
|
1602
|
+
receipt = plan.get("archive")
|
|
1603
|
+
if not isinstance(receipt, dict) or receipt.get("revision") != plan.get("revision"):
|
|
1604
|
+
raise ValueError("Archive this plan with verification and node assessment before plan-finish")
|
|
1605
|
+
require_human_work_review(root, session, plan)
|
|
1606
|
+
if scope_snapshot(root, plan["paths"]) != receipt.get("snapshot"):
|
|
1607
|
+
raise ValueError("Files changed after archive; verify and archive again")
|
|
1608
|
+
inbox = run_node_workbench(["map", "inbox", "--root", str(root), "--session", session])
|
|
1609
|
+
if inbox.get("pending"):
|
|
1610
|
+
raise ValueError("Other Map changes need review and acknowledgement before plan-finish")
|
|
1611
|
+
if sync_configured(root, session):
|
|
1612
|
+
if (plan.get("sync") or {}).get("pending"):
|
|
1613
|
+
plan["sync"] = checked_sync(root, session, "prepare", plan["paths"])
|
|
1614
|
+
write_hook_runtime(root, session, runtime)
|
|
1615
|
+
checked_sync(root, session, "checkpoint")
|
|
1616
|
+
finished = checked_sync(root, session, "finish")
|
|
1617
|
+
if finished.get("confirmed") is not True:
|
|
1618
|
+
raise ValueError("Cloud did not confirm completion")
|
|
1619
|
+
plan["status"] = "completed"
|
|
1620
|
+
plan["delivery"] = {"source": "verified", "memory": "confirmed"}
|
|
1621
|
+
plan["completed_at"] = utc_now()
|
|
1622
|
+
latest = read_hook_runtime(root, session)
|
|
1623
|
+
active = latest.get("active_plan") or {}
|
|
1624
|
+
if pending_signals(latest) or active.get("id") != plan["id"] or active.get("revision") != plan.get("revision"):
|
|
1625
|
+
raise ValueError("New prompts or tool activity arrived during finish; review before completing")
|
|
1626
|
+
runtime = latest
|
|
1627
|
+
runtime["last_plan"] = plan
|
|
1628
|
+
runtime["active_plan"] = None
|
|
1629
|
+
write_hook_runtime(root, session, runtime)
|
|
1630
|
+
append_session_event(root, command, "cli", session, {"plan_id": plan["id"], "occurred_at": utc_now(), "result": plan["status"]})
|
|
1631
|
+
return plan
|
|
225
1632
|
|
|
226
1633
|
|
|
227
1634
|
def main() -> int:
|
|
@@ -236,34 +1643,174 @@ def main() -> int:
|
|
|
236
1643
|
payload = parse_hook_payload(raw)
|
|
237
1644
|
root, root_source = event_root(raw, Path.cwd())
|
|
238
1645
|
ctx = context_folder(root)
|
|
239
|
-
current_session_id = session_id(payload, platform)
|
|
240
1646
|
|
|
241
|
-
if is_context_guard_skill_path(root):
|
|
1647
|
+
if is_context_guard_skill_path(root) and not initialized_source_project(root):
|
|
242
1648
|
hook_log("[context-guard] apparent root is the skill directory; skipping writes.")
|
|
243
1649
|
return hook_response(platform, event)
|
|
1650
|
+
current_session_id = session_id(payload, platform, ctx, event)
|
|
1651
|
+
current_session_name = session_display_name(payload, platform, root, current_session_id)
|
|
1652
|
+
memory_notice = ""
|
|
1653
|
+
verified_workbench_url = ""
|
|
244
1654
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
):
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
1655
|
+
def session_details(details: dict[str, object] | None = None) -> dict[str, object]:
|
|
1656
|
+
result = dict(details or {})
|
|
1657
|
+
result["worktree_root"] = str(root)
|
|
1658
|
+
if current_session_name:
|
|
1659
|
+
result["thread_name"] = current_session_name
|
|
1660
|
+
return result
|
|
1661
|
+
|
|
1662
|
+
# Binding precedes initialization, memory reads, and service startup.
|
|
1663
|
+
# The unbound lifecycle record is registration evidence, not project memory.
|
|
1664
|
+
if event in {"session-start", "subagent-start", "user-prompt-submit", "post-compact"}:
|
|
1665
|
+
try:
|
|
1666
|
+
binding = run_node_workbench(["workbench", "--binding-status", "--root", str(root), "--session", current_session_id])
|
|
1667
|
+
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
|
|
1668
|
+
return hook_response(platform, event, "Context Guard binding unreadable; preserve data. Run workbench --diagnose.")
|
|
1669
|
+
if not binding.get("session", {}).get("bound"):
|
|
1670
|
+
current_workbench = None
|
|
1671
|
+
try:
|
|
1672
|
+
inventory = run_node_workbench(["workbench", "--list", "--root", str(root)])
|
|
1673
|
+
candidate = inventory.get("currentProject")
|
|
1674
|
+
if isinstance(candidate, dict):
|
|
1675
|
+
current_workbench = candidate
|
|
1676
|
+
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
|
|
1677
|
+
# Binding diagnosis remains authoritative for this Session. A
|
|
1678
|
+
# failed global inventory must not create or select a service.
|
|
1679
|
+
current_workbench = None
|
|
1680
|
+
ctx.mkdir(parents=True, exist_ok=True)
|
|
1681
|
+
registration = {
|
|
1682
|
+
"at": utc_now(), "event": event, "platform": platform,
|
|
1683
|
+
"session_id": current_session_id, "thread_name": current_session_name,
|
|
1684
|
+
"binding": "required",
|
|
1685
|
+
}
|
|
1686
|
+
registration.update(audit_details(payload, event, current_session_id, {}, {"root_source": root_source}))
|
|
1687
|
+
with (ctx / "sessions.jsonl").open("a", encoding="utf-8") as stream:
|
|
1688
|
+
stream.write(json.dumps(registration, ensure_ascii=False) + "\n")
|
|
1689
|
+
main_notice = "First ask which remote/branch is authoritative; persist with workbench --bind-main or --local-main. " if binding.get("bindingRequired") else ""
|
|
1690
|
+
previous = binding.get("session", {})
|
|
1691
|
+
target = previous.get("worktreeRoot")
|
|
1692
|
+
prior = f" It is currently recorded against {target}; use --rebind only after explicit confirmation." if target else ""
|
|
1693
|
+
status = str(current_workbench.get("status") or "unknown") if current_workbench else ""
|
|
1694
|
+
established = bool(current_workbench and (current_workbench.get("registered") or current_workbench.get("runningInstances") == 1))
|
|
1695
|
+
binding_state = str(previous.get("state") or "unbound")
|
|
1696
|
+
safe_existing_binding = binding_state in {"current", "moved"}
|
|
1697
|
+
automatic = (not target or safe_existing_binding) and not binding.get("bindingRequired") and established and status in {
|
|
1698
|
+
"ready", "direct-only", "route-stale", "stopped", "upgrade-required"
|
|
1699
|
+
}
|
|
1700
|
+
if automatic:
|
|
1701
|
+
try:
|
|
1702
|
+
repaired = run_node_workbench([
|
|
1703
|
+
"workbench", "--root", str(root), "--session", current_session_id,
|
|
1704
|
+
])
|
|
1705
|
+
verified_workbench_url = str(repaired.get("url") or "")
|
|
1706
|
+
binding = run_node_workbench([
|
|
1707
|
+
"workbench", "--binding-status", "--root", str(root),
|
|
1708
|
+
"--session", current_session_id,
|
|
1709
|
+
])
|
|
1710
|
+
if not binding.get("session", {}).get("bound") or not binding.get("session", {}).get("verified"):
|
|
1711
|
+
raise RuntimeError("automatic binding receipt was not verified")
|
|
1712
|
+
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
|
|
1713
|
+
return hook_response(
|
|
1714
|
+
platform, event,
|
|
1715
|
+
"Context Guard automatic binding unverified. "
|
|
1716
|
+
f"Run {context_guard_cli()} workbench --root {json.dumps(str(root))} --session {json.dumps(current_session_id)} to retry the same binding. "
|
|
1717
|
+
"Existing workbench preserved.",
|
|
1718
|
+
)
|
|
1719
|
+
elif current_workbench:
|
|
1720
|
+
status = str(current_workbench.get("status") or "unknown")
|
|
1721
|
+
url = str(current_workbench.get("url") or "")
|
|
1722
|
+
readable = f" at {url}" if url else ""
|
|
1723
|
+
if status in {"legacy", "duplicate", "unknown", "route-mismatch"}:
|
|
1724
|
+
return hook_response(
|
|
1725
|
+
platform, event,
|
|
1726
|
+
main_notice + f"This Session is not bound to the current worktree.{prior} "
|
|
1727
|
+
f"The matching project workbench{readable} is {status}. Do not create or bind another service. "
|
|
1728
|
+
f"Run {context_guard_cli()} workbench --diagnose --root {json.dumps(str(root))} and resolve that runtime first.",
|
|
1729
|
+
)
|
|
1730
|
+
return hook_response(
|
|
1731
|
+
platform, event,
|
|
1732
|
+
main_notice + f"This Session is not bound to the current worktree.{prior} "
|
|
1733
|
+
f"The project workbench candidate{readable} is {status} and cannot be selected automatically. Ask which workbench to use. "
|
|
1734
|
+
"See SKILL.md for binding.",
|
|
1735
|
+
)
|
|
1736
|
+
elif not binding.get("session", {}).get("bound"):
|
|
1737
|
+
return hook_response(platform, event, main_notice + f"Context Guard: no established workbench for automatic Session binding.{prior} Ask the user for the project workbench URL; see SKILL.md for binding.")
|
|
1738
|
+
runtime_status = str(binding.get("runtime", {}).get("status") or "")
|
|
1739
|
+
if runtime_status in {"legacy", "duplicate", "unknown"}:
|
|
1740
|
+
return hook_response(platform, event, f"Context Guard binding exists, but the project workbench runtime is {runtime_status}. Do not create another service or ask to bind again. Run {context_guard_cli()} workbench --diagnose --root {json.dumps(str(root))} and follow the explicit migration result.")
|
|
1741
|
+
if not binding.get("session", {}).get("verified") and os.environ.get("CONTEXT_GUARD_DISABLE_WORKBENCH") != "1":
|
|
1742
|
+
try:
|
|
1743
|
+
repaired = run_node_workbench([
|
|
1744
|
+
"workbench", "--root", str(root), "--session", current_session_id,
|
|
1745
|
+
])
|
|
1746
|
+
verified_workbench_url = str(repaired.get("url") or "")
|
|
1747
|
+
binding = run_node_workbench([
|
|
1748
|
+
"workbench", "--binding-status", "--root", str(root),
|
|
1749
|
+
"--session", current_session_id,
|
|
1750
|
+
])
|
|
1751
|
+
if not binding.get("session", {}).get("verified"):
|
|
1752
|
+
raise RuntimeError("binding receipt was not verified")
|
|
1753
|
+
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
|
|
1754
|
+
return hook_response(
|
|
1755
|
+
platform, event,
|
|
1756
|
+
f"Context Guard kept the existing Session binding, but its project workbench could not be verified or repaired. "
|
|
1757
|
+
f"Run {context_guard_cli()} workbench --diagnose --root {json.dumps(str(root))}. "
|
|
1758
|
+
"Source inspection and recovery commands may continue; do not create a second workbench or ask the user to bind again.",
|
|
1759
|
+
)
|
|
1760
|
+
try:
|
|
1761
|
+
memory = run_node_workbench(["memory", "prepare", "--root", str(root), "--session", current_session_id])
|
|
1762
|
+
if memory.get("current"):
|
|
1763
|
+
hook_log(f"[context-guard] server memory confirmed: {memory.get('sessionVersion')}; cache: {memory.get('cache')}")
|
|
1764
|
+
memory_notice = f"Server memory confirmed; cache: {memory.get('cache')}."
|
|
1765
|
+
else:
|
|
1766
|
+
memory_notice = "Private memory unconfigured; local records are unsynced."
|
|
1767
|
+
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
|
|
1768
|
+
memory_notice = "Server memory unavailable or conflicting; local records unverified."
|
|
1769
|
+
if event not in {"session-start", "subagent-start", "user-prompt-submit", "post-compact"}:
|
|
1770
|
+
try:
|
|
1771
|
+
binding = run_node_workbench(["workbench", "--binding-status", "--root", str(root), "--session", current_session_id])
|
|
1772
|
+
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
|
|
1773
|
+
return hook_response(platform, event, "Context Guard binding unavailable; no initialization.")
|
|
1774
|
+
if not binding.get("session", {}).get("bound"):
|
|
1775
|
+
forbidden = forbidden_direct_write(payload, root) if event == "pre-tool-use" else ""
|
|
1776
|
+
if forbidden:
|
|
1777
|
+
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": forbidden}}))
|
|
1778
|
+
return 0
|
|
1779
|
+
return hook_response(platform, event, "Context Guard Session unbound; inspect workbench --list.")
|
|
1780
|
+
created = init_context(root)
|
|
1781
|
+
ensure_session_file(root, current_session_id, platform)
|
|
1782
|
+
# Keep the lease alive until this hook invocation returns. Kernel locks are
|
|
1783
|
+
# released even if the process crashes, so there is no stale lock cleanup.
|
|
1784
|
+
_runtime_lease = acquire_hook_runtime_lock(root, current_session_id)
|
|
1785
|
+
runtime = read_hook_runtime(root, current_session_id)
|
|
1786
|
+
|
|
1787
|
+
if event in {"session-start", "user-prompt-submit", "post-compact"}:
|
|
1788
|
+
sync_pending_interrupt(root, current_session_id, runtime)
|
|
1789
|
+
|
|
1790
|
+
if event == "session-start":
|
|
1791
|
+
# Registration must precede the inbox CLI's identity check.
|
|
1792
|
+
append_session_event(root, event, platform, current_session_id,
|
|
1793
|
+
session_details(audit_details(payload, event, current_session_id, runtime, {"root_source": root_source})))
|
|
1794
|
+
url = verified_workbench_url or None
|
|
1795
|
+
if not (isinstance(payload, dict) and payload.get("is_background_agent") is True):
|
|
1796
|
+
if not url:
|
|
1797
|
+
try:
|
|
1798
|
+
url = start_workbench(root, open_browser=False, raise_errors=True, session_id=current_session_id)
|
|
1799
|
+
except (OSError, RuntimeError, subprocess.TimeoutExpired):
|
|
1800
|
+
return hook_response(platform, event, f"Context Guard could not start or verify the bound project workbench. Run {context_guard_cli()} workbench --diagnose --root {json.dumps(str(root))}; no replacement service was started and the binding was preserved.")
|
|
1801
|
+
if sync_configured(root, current_session_id):
|
|
1802
|
+
sync_command(root, "ensure", current_session_id)
|
|
1803
|
+
synchronized_memory = session_memory_sync(root, current_session_id, event, payload)
|
|
1804
|
+
if isinstance(synchronized_memory.get("error"), dict):
|
|
1805
|
+
memory_notice = "Cloud Session registration pending; local records preserved."
|
|
1806
|
+
context_text, snapshot = map_context(root, ctx, current_session_id)
|
|
1807
|
+
runtime["last_map_version"] = snapshot.get("version")
|
|
1808
|
+
runtime["last_cloud_cursor"] = snapshot.get("cloud_cursor")
|
|
1809
|
+
runtime["last_session_start"] = payload_time(payload)
|
|
1810
|
+
write_hook_runtime(root, current_session_id, runtime)
|
|
1811
|
+
hook_log(f"[context-guard] {'initialized' if created else 'ready'} {ctx} ({root_source})")
|
|
1812
|
+
contexts = [memory_notice, language_setup_context(root, ctx), context_text,
|
|
1813
|
+
lifecycle_context(root, frontend_workbench_url(ctx, url, current_session_id), current_session_id)]
|
|
267
1814
|
playbook = ctx / "tasks" / "J2.md"
|
|
268
1815
|
if playbook.is_file():
|
|
269
1816
|
contexts.append(
|
|
@@ -272,28 +1819,282 @@ def main() -> int:
|
|
|
272
1819
|
)
|
|
273
1820
|
return hook_response(platform, event, "\n\n".join(item for item in contexts if item))
|
|
274
1821
|
|
|
1822
|
+
if event == "subagent-start":
|
|
1823
|
+
context_text, snapshot = map_context(root, ctx, current_session_id)
|
|
1824
|
+
plan = runtime.get("active_plan") if isinstance(runtime.get("active_plan"), dict) else {}
|
|
1825
|
+
agent_id = payload_value(payload, ("agent_id", "agentId"))
|
|
1826
|
+
agent_type = payload_value(payload, ("agent_type", "agentType"))
|
|
1827
|
+
subagents = runtime.get("subagents") if isinstance(runtime.get("subagents"), dict) else {}
|
|
1828
|
+
subagents[agent_id or f"unknown-{len(subagents) + 1}"] = {
|
|
1829
|
+
"type": agent_type,
|
|
1830
|
+
"started_at": payload_time(payload),
|
|
1831
|
+
"plan_id": plan.get("id") if isinstance(plan, dict) else None,
|
|
1832
|
+
"node_ids": list(plan.get("node_ids") or []) if isinstance(plan, dict) else [],
|
|
1833
|
+
"paths": list(plan.get("paths") or []) if isinstance(plan, dict) else [],
|
|
1834
|
+
"status": "working",
|
|
1835
|
+
}
|
|
1836
|
+
runtime["subagents"] = subagents
|
|
1837
|
+
write_hook_runtime(root, current_session_id, runtime)
|
|
1838
|
+
append_session_event(root, event, platform, current_session_id, session_details(audit_details(
|
|
1839
|
+
payload, event, current_session_id, runtime,
|
|
1840
|
+
{"agent_id": agent_id or None, "agent_type": agent_type or None, "node_ids": plan.get("node_ids", []), "paths": plan.get("paths", [])},
|
|
1841
|
+
)))
|
|
1842
|
+
boundary = (
|
|
1843
|
+
"Subagent scope is limited to the parent plan nodes "
|
|
1844
|
+
f"{', '.join(plan.get('node_ids') or []) or 'none'} and paths {', '.join(plan.get('paths') or []) or 'none'}. "
|
|
1845
|
+
)
|
|
1846
|
+
return hook_response(platform, event, context_text + "\n\n" + boundary)
|
|
1847
|
+
|
|
1848
|
+
if event == "pre-tool-use":
|
|
1849
|
+
if not mutating_tool(payload):
|
|
1850
|
+
return hook_response(platform, event)
|
|
1851
|
+
forbidden = forbidden_direct_write(payload, root)
|
|
1852
|
+
if forbidden:
|
|
1853
|
+
append_session_event(root, event, platform, current_session_id, session_details(audit_details(
|
|
1854
|
+
payload, event, current_session_id, runtime, {"result": "denied", "error": "DIRECT_CONTEXT_WRITE"},
|
|
1855
|
+
)))
|
|
1856
|
+
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": forbidden}}, ensure_ascii=False))
|
|
1857
|
+
return 0
|
|
1858
|
+
paths = tool_paths(payload, root)
|
|
1859
|
+
# Host-temp JSON request files are not product implementation.
|
|
1860
|
+
if write_like_tool(payload) and not paths and protocol_request_write(payload, root):
|
|
1861
|
+
return hook_response(platform, event)
|
|
1862
|
+
if platform == "claude":
|
|
1863
|
+
execution = run_node_workbench(["map", "execution", "--root", str(root), "--session", current_session_id])
|
|
1864
|
+
ci = execution.get("active") or {}
|
|
1865
|
+
ci_allowed = ci_tool_allowed(payload, execution)
|
|
1866
|
+
if ci_allowed is not None:
|
|
1867
|
+
if ci_allowed:
|
|
1868
|
+
append_session_event(root, event, platform, current_session_id, session_details(audit_details(
|
|
1869
|
+
payload, event, current_session_id, runtime, {"result": "ci-test-authorized", "sourceSha": ci.get("sourceSha"), "taskId": ci.get("taskId")},
|
|
1870
|
+
)))
|
|
1871
|
+
return hook_response(platform, event)
|
|
1872
|
+
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "CI may run only its assigned test commands; business source edits are forbidden."}}, ensure_ascii=False))
|
|
1873
|
+
return 0
|
|
1874
|
+
if gh_pr_merge_command(payload) and not post_plan_delivery_command(payload, root):
|
|
1875
|
+
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Merge only with one standalone gh pr merge NUMBER command after accepted, archived work; shell chaining and --admin are not permitted."}}, ensure_ascii=False))
|
|
1876
|
+
return 0
|
|
1877
|
+
if post_plan_delivery_command(payload, root):
|
|
1878
|
+
reason = ""
|
|
1879
|
+
if not post_plan_delivery_ready(runtime, execution, root):
|
|
1880
|
+
reason = "Post-plan delivery needs the accepted task, archived Plan and clean worktree."
|
|
1881
|
+
elif (number := post_plan_merge_target(payload)) and not verified_merge_checks(root, number):
|
|
1882
|
+
reason = "GitHub PR is not ready: every check must be complete and green before merge."
|
|
1883
|
+
if reason:
|
|
1884
|
+
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason}}, ensure_ascii=False))
|
|
1885
|
+
return 0
|
|
1886
|
+
append_session_event(root, event, platform, current_session_id, session_details(audit_details(
|
|
1887
|
+
payload, event, current_session_id, runtime,
|
|
1888
|
+
{"result": "post-plan-delivery", "taskId": ci.get("taskId")},
|
|
1889
|
+
)))
|
|
1890
|
+
return hook_response(platform, event)
|
|
1891
|
+
snapshot = map_snapshot(ctx, current_session_id)
|
|
1892
|
+
owners = owner_nodes(paths, snapshot)
|
|
1893
|
+
missing = sorted(set(owners.values()) - active_grants(snapshot))
|
|
1894
|
+
if missing:
|
|
1895
|
+
message = "Context Guard authorization required for Map node(s): " + ", ".join(missing) + ". Confirm the scope in the workbench, then retry the same tool."
|
|
1896
|
+
append_session_event(root, event, platform, current_session_id, session_details(audit_details(
|
|
1897
|
+
payload, event, current_session_id, runtime,
|
|
1898
|
+
{"result": "denied", "error": "MAP_SCOPE_REQUIRED", "node_ids": sorted(set(owners.values())), "paths": paths},
|
|
1899
|
+
)))
|
|
1900
|
+
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": message}}, ensure_ascii=False))
|
|
1901
|
+
return 0
|
|
1902
|
+
plan = runtime.get("active_plan") if isinstance(runtime.get("active_plan"), dict) else None
|
|
1903
|
+
reason = ""
|
|
1904
|
+
if pending_signals(runtime):
|
|
1905
|
+
reason = pending_signal_guidance(root, current_session_id, runtime)
|
|
1906
|
+
elif not plan or plan.get("status") != "working":
|
|
1907
|
+
reason = "No active plan. Pipe authorized plan JSON to plan-start --input -."
|
|
1908
|
+
elif any(not in_scope(file, plan["paths"]) for file in paths) or set(owners.values()) - set(plan["node_ids"]):
|
|
1909
|
+
reason = "Tool exceeds the approved plan scope; do not silently expand it."
|
|
1910
|
+
if reason:
|
|
1911
|
+
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason}}, ensure_ascii=False))
|
|
1912
|
+
return 0
|
|
1913
|
+
# Unknown scripts may mutate more than declared paths. Never certify their
|
|
1914
|
+
# scope from command text; require an explicit review in the archive.
|
|
1915
|
+
if not paths:
|
|
1916
|
+
plan["scope_review_required"] = True
|
|
1917
|
+
plan["revision"] = int(plan.get("revision", 0)) + 1
|
|
1918
|
+
plan.pop("archive", None)
|
|
1919
|
+
write_hook_runtime(root, current_session_id, runtime)
|
|
1920
|
+
append_session_event(root, event, platform, current_session_id, session_details(audit_details(
|
|
1921
|
+
payload, event, current_session_id, runtime,
|
|
1922
|
+
{"result": "checked" if paths else "scope-unknown", "map_version": snapshot.get("version"), "node_ids": sorted(set(owners.values())), "paths": paths},
|
|
1923
|
+
)))
|
|
1924
|
+
return hook_response(platform, event)
|
|
1925
|
+
|
|
1926
|
+
if event == "permission-request":
|
|
1927
|
+
paths = tool_paths(payload, root)
|
|
1928
|
+
snapshot = map_snapshot(ctx, current_session_id)
|
|
1929
|
+
owners = owner_nodes(paths, snapshot)
|
|
1930
|
+
missing = sorted(set(owners.values()) - active_grants(snapshot))
|
|
1931
|
+
append_session_event(root, event, platform, current_session_id, session_details(audit_details(
|
|
1932
|
+
payload, event, current_session_id, runtime,
|
|
1933
|
+
{"result": "denied" if missing else "deferred-to-user", "node_ids": sorted(set(owners.values())), "paths": paths},
|
|
1934
|
+
)))
|
|
1935
|
+
if missing:
|
|
1936
|
+
return permission_response(event, "deny", "Context Guard Map authorization is missing for: " + ", ".join(missing) + ". Authorize it in the workbench first.")
|
|
1937
|
+
return permission_response(event)
|
|
1938
|
+
|
|
1939
|
+
if event in {"post-tool-use", "post-tool-use-failure"}:
|
|
1940
|
+
if event == "post-tool-use" and control_tool(payload):
|
|
1941
|
+
return hook_response(platform, event)
|
|
1942
|
+
plan = runtime.get("active_plan") if isinstance(runtime.get("active_plan"), dict) else None
|
|
1943
|
+
paths = tool_paths(payload, root)
|
|
1944
|
+
if plan:
|
|
1945
|
+
before = plan.get("baseline") or {}
|
|
1946
|
+
after = scope_snapshot(root, plan["paths"])
|
|
1947
|
+
observed = {file for file in set(before) | set(after) if before.get(file) != after.get(file)}
|
|
1948
|
+
paths = sorted(set(paths) | observed)
|
|
1949
|
+
plan["actual_paths"] = sorted(set((plan.get("actual_paths") or []) + paths))
|
|
1950
|
+
# Local observations only; Cloud upload/check happens at plan-finish.
|
|
1951
|
+
write_hook_runtime(root, current_session_id, runtime)
|
|
1952
|
+
failed = event == "post-tool-use-failure" or bool(isinstance(payload, dict) and (payload.get("error") or payload.get("is_error") is True))
|
|
1953
|
+
response = payload.get("tool_response", {}) if isinstance(payload, dict) else {}
|
|
1954
|
+
failed = failed or (isinstance(response, dict) and (response.get("isError") is True or bool(response.get("exit_code"))))
|
|
1955
|
+
if plan and failed:
|
|
1956
|
+
plan["failure_review_required"] = True
|
|
1957
|
+
write_hook_runtime(root, current_session_id, runtime)
|
|
1958
|
+
append_session_event(root, "toolFailure" if failed else event, platform, current_session_id, session_details(audit_details(
|
|
1959
|
+
payload, event, current_session_id, runtime, {"result": "failed" if failed else "completed", "paths": paths},
|
|
1960
|
+
)))
|
|
1961
|
+
return hook_response(platform, event)
|
|
1962
|
+
|
|
275
1963
|
if event == "user-prompt-submit":
|
|
276
|
-
|
|
277
|
-
|
|
1964
|
+
prompt = prompt_text(raw)
|
|
1965
|
+
turn_id = payload_value(payload, ("turn_id", "turnId"))
|
|
1966
|
+
signal = add_prompt_signal(root, current_session_id, turn_id, prompt) if prompt.strip() else None
|
|
1967
|
+
runtime = read_hook_runtime(root, current_session_id)
|
|
1968
|
+
runtime["current_turn_id"] = turn_id or None
|
|
1969
|
+
runtime["last_prompt_signal"] = signal.get("id") if signal else None
|
|
1970
|
+
write_hook_runtime(root, current_session_id, runtime)
|
|
1971
|
+
status = append_user_message(ctx, prompt, current_session_id)
|
|
1972
|
+
context_text, snapshot = map_context(root, ctx, current_session_id)
|
|
278
1973
|
append_session_event(
|
|
279
1974
|
root,
|
|
280
1975
|
event,
|
|
281
1976
|
platform,
|
|
282
1977
|
current_session_id,
|
|
283
|
-
|
|
1978
|
+
session_details(audit_details(payload, event, current_session_id, runtime, {
|
|
1979
|
+
"message_status": status,
|
|
1980
|
+
"signal_id": signal.get("id") if signal else None,
|
|
1981
|
+
"prompt_hash": signal.get("prompt_hash") if signal else None,
|
|
1982
|
+
"map_version": snapshot.get("version"),
|
|
1983
|
+
"cloud_cursor": snapshot.get("cloud_cursor"),
|
|
1984
|
+
})),
|
|
284
1985
|
)
|
|
1986
|
+
synchronized_memory = session_memory_sync(root, current_session_id, event, payload)
|
|
1987
|
+
if isinstance(synchronized_memory.get("error"), dict):
|
|
1988
|
+
memory_notice = "Cloud Session sync pending; local records preserved."
|
|
285
1989
|
hook_log(f"[context-guard] user-messages: {status}")
|
|
1990
|
+
signal_id = str(signal.get("id")) if signal else "none"
|
|
1991
|
+
pending_ids = pending_signals(runtime)
|
|
1992
|
+
pending_notice = ", ".join(pending_ids[:20]) or "none"
|
|
1993
|
+
if len(pending_ids) > 20:
|
|
1994
|
+
pending_notice += f" (+{len(pending_ids) - 20} more; use plan-status for the complete list)"
|
|
1995
|
+
plan = runtime.get("active_plan") if isinstance(runtime.get("active_plan"), dict) else None
|
|
1996
|
+
plan_notice = ""
|
|
1997
|
+
if plan:
|
|
1998
|
+
plan_notice = (
|
|
1999
|
+
f" Active plan: {plan.get('id')} ({plan.get('summary') or 'unfinished'}). "
|
|
2000
|
+
)
|
|
2001
|
+
notice = (
|
|
2002
|
+
context_text + "\n\n" +
|
|
2003
|
+
f"User signal: {signal_id}. Pending user signals: {pending_notice}." + plan_notice
|
|
2004
|
+
)
|
|
2005
|
+
return hook_response(platform, event, memory_notice + "\n\n" + notice)
|
|
2006
|
+
|
|
2007
|
+
if event == "pre-compact":
|
|
2008
|
+
plan = runtime.get("active_plan") if isinstance(runtime.get("active_plan"), dict) else None
|
|
2009
|
+
runtime["compact_snapshot"] = {
|
|
2010
|
+
"at": utc_now(),
|
|
2011
|
+
"turn_id": payload_value(payload, ("turn_id", "turnId")) or None,
|
|
2012
|
+
"trigger": payload_value(payload, ("trigger",)),
|
|
2013
|
+
"active_plan": json.loads(json.dumps(plan)) if plan else None,
|
|
2014
|
+
"pending_signals": [item.get("id") for item in runtime.get("signals") or [] if isinstance(item, dict) and item.get("status") == "pending"],
|
|
2015
|
+
}
|
|
2016
|
+
write_hook_runtime(root, current_session_id, runtime)
|
|
2017
|
+
append_session_event(root, event, platform, current_session_id, session_details(audit_details(payload, event, current_session_id, runtime, {"result": "saved"})))
|
|
286
2018
|
return hook_response(platform, event)
|
|
287
2019
|
|
|
288
|
-
if event
|
|
289
|
-
|
|
290
|
-
append_session_event(root, event, platform, current_session_id
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
)
|
|
2020
|
+
if event == "post-compact":
|
|
2021
|
+
context_text, snapshot = map_context(root, ctx, current_session_id)
|
|
2022
|
+
append_session_event(root, event, platform, current_session_id, session_details(audit_details(
|
|
2023
|
+
payload, event, current_session_id, runtime, {"result": "restored", "map_version": snapshot.get("version"), "cloud_cursor": snapshot.get("cloud_cursor")},
|
|
2024
|
+
)))
|
|
2025
|
+
synchronized_memory = session_memory_sync(root, current_session_id, event, payload)
|
|
2026
|
+
if isinstance(synchronized_memory.get("error"), dict):
|
|
2027
|
+
memory_notice = "Cloud Session sync pending; local records preserved."
|
|
2028
|
+
plan = runtime.get("active_plan")
|
|
2029
|
+
restored = f"Restored plan: {plan.get('id')} with paths {', '.join(plan.get('actual_paths') or plan.get('paths') or [])}." if isinstance(plan, dict) else "No active development plan was present before compaction."
|
|
2030
|
+
restored += " Pending signals: " + (", ".join(pending_signals(runtime)) or "none") + "."
|
|
2031
|
+
return hook_response(platform, event, memory_notice + "\n\n" + context_text + "\n\n" + restored)
|
|
2032
|
+
|
|
2033
|
+
if event in {"interrupt", "stop-failure"}:
|
|
2034
|
+
event_id, _, _ = event_identity(payload, event, current_session_id)
|
|
2035
|
+
runtime["interrupted"] = {
|
|
2036
|
+
"at": utc_now(),
|
|
2037
|
+
"turn_id": payload_value(payload, ("turn_id", "turnId")) or None,
|
|
2038
|
+
"plan_id": (runtime.get("active_plan") or {}).get("id") if isinstance(runtime.get("active_plan"), dict) else None,
|
|
2039
|
+
"status": "interrupted",
|
|
2040
|
+
}
|
|
2041
|
+
pending = runtime.setdefault("pending_interrupts", [])
|
|
2042
|
+
if not any(item.get("id") == event_id for item in pending):
|
|
2043
|
+
pending.append({"id": event_id, "at": runtime["interrupted"]["at"]})
|
|
2044
|
+
write_hook_runtime(root, current_session_id, runtime)
|
|
2045
|
+
sync_pending_interrupt(root, current_session_id, runtime)
|
|
2046
|
+
append_session_event(root, event, platform, current_session_id, session_details(audit_details(payload, event, current_session_id, runtime, {"result": "interrupted"})))
|
|
2047
|
+
print(json.dumps({"systemMessage": "Context Guard: interrupted; plan preserved."}, ensure_ascii=False))
|
|
2048
|
+
return 0
|
|
2049
|
+
|
|
2050
|
+
if event == "session-end":
|
|
2051
|
+
append_session_event(root, event, platform, current_session_id, session_details(audit_details(
|
|
2052
|
+
payload, event, current_session_id, runtime, {"result": "ended", "reason": payload_value(payload, ("reason",))},
|
|
2053
|
+
)))
|
|
295
2054
|
return hook_response(platform, event)
|
|
296
2055
|
|
|
2056
|
+
if event == "subagent-stop":
|
|
2057
|
+
agent_id = payload_value(payload, ("agent_id", "agentId"))
|
|
2058
|
+
subagents = runtime.get("subagents") if isinstance(runtime.get("subagents"), dict) else {}
|
|
2059
|
+
record = subagents.get(agent_id) if isinstance(subagents.get(agent_id), dict) else {}
|
|
2060
|
+
record.update({
|
|
2061
|
+
"status": "stopped",
|
|
2062
|
+
"stopped_at": payload_time(payload),
|
|
2063
|
+
"last_message_hash": hashlib.sha256(payload_value(payload, ("last_assistant_message", "lastAssistantMessage")).encode("utf-8")).hexdigest(),
|
|
2064
|
+
})
|
|
2065
|
+
subagents[agent_id or f"unknown-{len(subagents) + 1}"] = record
|
|
2066
|
+
runtime["subagents"] = subagents
|
|
2067
|
+
write_hook_runtime(root, current_session_id, runtime)
|
|
2068
|
+
append_session_event(root, event, platform, current_session_id, session_details(audit_details(payload, event, current_session_id, runtime, {"result": "stopped", "agent_id": agent_id or None})))
|
|
2069
|
+
print(json.dumps({"systemMessage": "Context Guard: subagent stopped; review pending."}, ensure_ascii=False))
|
|
2070
|
+
return 0
|
|
2071
|
+
|
|
2072
|
+
if event == "stop":
|
|
2073
|
+
pending = pending_signals(runtime)
|
|
2074
|
+
plan = runtime.get("active_plan") if isinstance(runtime.get("active_plan"), dict) else None
|
|
2075
|
+
# Codex renders Stop block reasons as a user-role hook_prompt. Cursor and
|
|
2076
|
+
# Claude still rely on the block contract to continue an unfinished turn.
|
|
2077
|
+
blocked = bool((plan or pending) and platform != "codex")
|
|
2078
|
+
# Stop checks local receipts only. Network work belongs to explicit plan
|
|
2079
|
+
# boundaries and cannot time out this short hook into false success.
|
|
2080
|
+
result = "incomplete" if blocked else "deferred" if (plan or pending) else "completed"
|
|
2081
|
+
append_session_event(root, "stop-blocked" if blocked else event, platform, current_session_id,
|
|
2082
|
+
session_details(audit_details(payload, event, current_session_id, runtime,
|
|
2083
|
+
{"result": result, "state": "active" if blocked else "stopped",
|
|
2084
|
+
"pending_signal_count": len(pending)})))
|
|
2085
|
+
if platform == "codex" or not blocked:
|
|
2086
|
+
# Pending signals and active plans are already durable and will be
|
|
2087
|
+
# re-injected through the next UserPromptSubmit additionalContext.
|
|
2088
|
+
# Any Codex Stop block text becomes a user-visible hook_prompt.
|
|
2089
|
+
print("{}")
|
|
2090
|
+
elif isinstance(payload, dict) and payload.get("stop_hook_active") is True:
|
|
2091
|
+
# Hosts may stop retrying hooks; allow reporting the blocker without
|
|
2092
|
+
# converting unfinished work into a completion receipt or looping.
|
|
2093
|
+
print(json.dumps({"systemMessage": "Context Guard is still finishing the current task. No user action is required."}, ensure_ascii=False))
|
|
2094
|
+
else:
|
|
2095
|
+
print(json.dumps({"decision": "block", "reason": "Context Guard is finishing the current task. No user action is required."}, ensure_ascii=False))
|
|
2096
|
+
return 0
|
|
2097
|
+
|
|
297
2098
|
hook_log(f"[context-guard] ignored event: {event}")
|
|
298
2099
|
return hook_response(platform, event)
|
|
299
2100
|
|