@michelj/context-guard 0.4.3 → 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 +89 -224
- package/README.zh-CN.md +89 -224
- package/SKILL.md +26 -684
- package/THIRD_PARTY_NOTICES.md +47 -0
- package/Tester.md +53 -0
- package/agents/openai.yaml +2 -2
- package/bin/build-runtime.mjs +96 -0
- package/bin/context-guard-skill.js +399 -78
- package/bin/postinstall.js +2 -2
- package/hooks.json +89 -13
- 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 +35 -6
- 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 +211 -0
- 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 +1366 -7602
- package/scripts/context_guard_hook.py +1960 -711
- package/scripts/map_owns.py +699 -0
- 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/context-template.md +0 -341
- package/references/feature-chain-methodology.md +0 -228
- package/references/register-template.md +0 -85
- package/references/task-case-template.md +0 -63
- package/tests/BC-20260618-063.sh +0 -116
- package/tests/BC-20260618-065.sh +0 -66
- package/tests/BC-20260626-080.sh +0 -48
- package/tests/BC-20260626-081.sh +0 -40
- package/tests/BC-20260626-082.sh +0 -32
- package/tests/BC-20260626-083.sh +0 -66
- package/tests/BC-20260627-084.sh +0 -74
- package/tests/BC-20260630-086.sh +0 -50
- package/tests/BC-20260630-087.sh +0 -103
- package/tests/BC-20260630-088.sh +0 -32
- package/tests/BC-20260630-089.sh +0 -63
- package/tests/BC-20260701-090.sh +0 -84
- package/tests/BC-20260702-096.sh +0 -48
- package/tests/BC-20260706-098.sh +0 -66
- package/tests/BC-20260707-099.sh +0 -47
- package/tests/BC-20260707-100.sh +0 -46
- package/tests/BC-20260707-101.sh +0 -47
- package/tests/BC-20260707-102.sh +0 -68
- package/tests/BC-20260707-103.sh +0 -59
- package/tests/BC-20260707-104.sh +0 -103
- package/tests/BC-20260707-105.sh +0 -109
- package/tests/BC-20260707-106.sh +0 -80
- package/tests/BC-20260707-107.sh +0 -74
- package/tests/BC-20260707-108.sh +0 -48
- package/tests/BC-20260707-109.sh +0 -56
- package/tests/BC-20260707-110.sh +0 -71
- package/tests/BC-20260707-111.sh +0 -70
- package/tests/BC-20260707-112.sh +0 -45
- package/tests/BC-20260707-113.sh +0 -73
- package/tests/BC-20260707-115.sh +0 -77
- package/tests/BC-20260707-116.sh +0 -77
- package/tests/BC-20260707-118.sh +0 -115
- package/tests/BC-20260707-119.sh +0 -47
- package/tests/BC-20260707-120.sh +0 -60
- package/tests/BC-20260707-121.sh +0 -66
- package/tests/BC-20260707-122.sh +0 -48
- package/tests/BC-20260707-123.sh +0 -43
- package/tests/BC-20260707-124.sh +0 -56
- package/tests/BC-20260707-125.sh +0 -64
- package/tests/BC-20260707-126.sh +0 -80
- package/tests/BC-20260707-127.sh +0 -88
- package/tests/BC-20260707-129.sh +0 -59
- package/tests/BC-20260707-130.sh +0 -69
- package/tests/BC-20260707-131.sh +0 -140
- package/tests/BC-20260707-132.sh +0 -150
- package/tests/BC-20260707-133.sh +0 -70
- package/tests/BC-20260708-136.sh +0 -210
- package/tests/BC-20260708-137.sh +0 -106
- package/tests/BC-20260708-138.sh +0 -168
- package/tests/BC-20260708-139.sh +0 -79
- package/tests/BC-20260709-002.sh +0 -63
- package/tests/BC-20260709-003.sh +0 -239
- package/tests/BC-20260709-006.sh +0 -76
- package/tests/BC-20260709-008.sh +0 -168
- package/tests/BC-20260710-001.sh +0 -61
- package/tests/BC-20260710-002.sh +0 -111
- package/tests/npm-install-smoke.sh +0 -53
|
@@ -1,25 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
|
|
4
|
-
The hook initializes folder-scoped context on session/subagent start and nudges
|
|
5
|
-
Codex to use the context-guard skill at the moments where omission is most
|
|
6
|
-
costly: prompt intake, turn stop, and subagent stop.
|
|
7
|
-
"""
|
|
2
|
+
"""Normalize Codex, Cursor, and Claude lifecycle hooks for Context Guard."""
|
|
8
3
|
|
|
9
4
|
from __future__ import annotations
|
|
10
5
|
|
|
6
|
+
import argparse
|
|
11
7
|
import json
|
|
8
|
+
import hashlib
|
|
12
9
|
import os
|
|
13
10
|
import re
|
|
11
|
+
import shlex
|
|
12
|
+
import sqlite3
|
|
14
13
|
import subprocess
|
|
15
14
|
import sys
|
|
16
|
-
|
|
17
|
-
from
|
|
15
|
+
import tempfile
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path, PureWindowsPath
|
|
18
|
+
from urllib.parse import quote, urlsplit
|
|
19
|
+
|
|
20
|
+
from context_guard import acquire_hook_runtime_lock, append_session_event
|
|
21
|
+
from context_guard import add_prompt_signal
|
|
22
|
+
from context_guard import context_dir as context_folder
|
|
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
|
|
18
27
|
|
|
19
|
-
|
|
28
|
+
WINDOWS_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
20
29
|
|
|
21
30
|
|
|
22
|
-
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
23
31
|
WORKSPACE_KEYS = {
|
|
24
32
|
"cwd",
|
|
25
33
|
"current_working_directory",
|
|
@@ -28,6 +36,8 @@ WORKSPACE_KEYS = {
|
|
|
28
36
|
"workspace_root",
|
|
29
37
|
"workspaceFolder",
|
|
30
38
|
"workspace_folder",
|
|
39
|
+
"workspace_roots",
|
|
40
|
+
"workspaceRoots",
|
|
31
41
|
"project",
|
|
32
42
|
"project_root",
|
|
33
43
|
"projectRoot",
|
|
@@ -41,68 +51,53 @@ WORKSPACE_ENV_KEYS = [
|
|
|
41
51
|
"CODEX_WORKSPACE_ROOT",
|
|
42
52
|
"CODEX_PROJECT_ROOT",
|
|
43
53
|
"CODEX_CWD",
|
|
54
|
+
"CURSOR_PROJECT_DIR",
|
|
55
|
+
"CURSOR_WORKSPACE_ROOT",
|
|
56
|
+
"CLAUDE_PROJECT_DIR",
|
|
57
|
+
"CLAUDE_WORKSPACE_ROOT",
|
|
44
58
|
"WORKSPACE_ROOT",
|
|
45
59
|
"PROJECT_ROOT",
|
|
46
60
|
"PWD",
|
|
47
61
|
]
|
|
48
62
|
|
|
49
63
|
|
|
50
|
-
def is_inside(path: Path, parent: Path) -> bool:
|
|
51
|
-
try:
|
|
52
|
-
path.resolve().relative_to(parent.resolve())
|
|
53
|
-
return True
|
|
54
|
-
except ValueError:
|
|
55
|
-
return False
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def is_context_guard_skill_path(path: Path) -> bool:
|
|
59
|
-
return is_inside(path, SKILL_ROOT)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def git_root(cwd: Path) -> Path:
|
|
63
|
-
try:
|
|
64
|
-
out = subprocess.check_output(
|
|
65
|
-
["git", "rev-parse", "--show-toplevel"],
|
|
66
|
-
cwd=str(cwd),
|
|
67
|
-
stderr=subprocess.DEVNULL,
|
|
68
|
-
text=True,
|
|
69
|
-
timeout=2,
|
|
70
|
-
).strip()
|
|
71
|
-
if out:
|
|
72
|
-
return Path(out)
|
|
73
|
-
except Exception:
|
|
74
|
-
pass
|
|
75
|
-
return cwd
|
|
76
|
-
|
|
77
|
-
|
|
78
64
|
def possible_workspace_paths(value: object) -> list[Path]:
|
|
79
65
|
paths: list[Path] = []
|
|
80
66
|
|
|
81
67
|
def add_path(candidate: object) -> None:
|
|
68
|
+
if isinstance(candidate, list):
|
|
69
|
+
for item in candidate:
|
|
70
|
+
add_path(item)
|
|
71
|
+
return
|
|
82
72
|
if not isinstance(candidate, str):
|
|
83
73
|
return
|
|
84
|
-
text = candidate.strip()
|
|
85
|
-
if not text
|
|
74
|
+
text = os.path.expandvars(os.path.expanduser(candidate.strip()))
|
|
75
|
+
if not text:
|
|
86
76
|
return
|
|
87
|
-
|
|
77
|
+
is_absolute = Path(text).is_absolute() or PureWindowsPath(text).is_absolute()
|
|
78
|
+
if not is_absolute:
|
|
79
|
+
return
|
|
80
|
+
path = Path(text)
|
|
88
81
|
if path.exists():
|
|
89
|
-
paths.append(path)
|
|
82
|
+
paths.append(path if path.is_dir() else path.parent)
|
|
90
83
|
|
|
91
|
-
def walk(obj: object
|
|
84
|
+
def walk(obj: object) -> None:
|
|
92
85
|
if isinstance(obj, dict):
|
|
93
86
|
for key, child in obj.items():
|
|
94
87
|
if key in WORKSPACE_KEYS:
|
|
95
88
|
add_path(child)
|
|
96
|
-
walk(child
|
|
89
|
+
walk(child)
|
|
97
90
|
elif isinstance(obj, list):
|
|
98
91
|
for child in obj:
|
|
99
|
-
walk(child
|
|
92
|
+
walk(child)
|
|
100
93
|
|
|
101
94
|
walk(value)
|
|
102
95
|
return paths
|
|
103
96
|
|
|
104
97
|
|
|
105
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")
|
|
106
101
|
if not raw.strip():
|
|
107
102
|
return {}
|
|
108
103
|
try:
|
|
@@ -111,743 +106,1997 @@ def parse_hook_payload(raw: str) -> object:
|
|
|
111
106
|
return {}
|
|
112
107
|
|
|
113
108
|
|
|
114
|
-
def
|
|
115
|
-
|
|
116
|
-
found: list[str] = []
|
|
117
|
-
|
|
118
|
-
def walk(value: object) -> None:
|
|
119
|
-
if isinstance(value, dict):
|
|
120
|
-
for key, child in value.items():
|
|
121
|
-
if key in keys and isinstance(child, str) and child.strip():
|
|
122
|
-
found.append(child.strip())
|
|
123
|
-
walk(child)
|
|
124
|
-
elif isinstance(value, list):
|
|
125
|
-
for child in value:
|
|
126
|
-
walk(child)
|
|
127
|
-
|
|
128
|
-
walk(payload)
|
|
129
|
-
return found[0] if found else ""
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def hook_text_field(payload: object, key: str) -> str:
|
|
133
|
-
if not isinstance(payload, dict):
|
|
134
|
-
return ""
|
|
135
|
-
value = payload.get(key)
|
|
136
|
-
return value.strip() if isinstance(value, str) else ""
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def registered_subagent_control_root(payload: object, cwd: Path, agent_id: str) -> Path | None:
|
|
140
|
-
if not agent_id:
|
|
141
|
-
return None
|
|
142
|
-
candidates = [*possible_workspace_paths(payload), cwd]
|
|
143
|
-
seen: set[Path] = set()
|
|
144
|
-
for path in candidates:
|
|
145
|
-
control_root = git_root(path).resolve()
|
|
146
|
-
if control_root in seen:
|
|
147
|
-
continue
|
|
148
|
-
seen.add(control_root)
|
|
149
|
-
if resolve_registered_subagent_root(control_root, agent_id):
|
|
150
|
-
return control_root
|
|
151
|
-
return None
|
|
109
|
+
def initialized_source_project(root: Path) -> bool:
|
|
110
|
+
return (root / ".git").exists() and (root / ".codex" / "context" / "map.json").is_file()
|
|
152
111
|
|
|
153
112
|
|
|
154
|
-
def event_root(raw: str, cwd: Path
|
|
113
|
+
def event_root(raw: str, cwd: Path) -> tuple[Path, str]:
|
|
155
114
|
payload = parse_hook_payload(raw)
|
|
156
115
|
candidates: list[tuple[Path, str]] = []
|
|
157
116
|
for path in possible_workspace_paths(payload):
|
|
158
117
|
candidates.append((path, "hook payload"))
|
|
159
118
|
for key in WORKSPACE_ENV_KEYS:
|
|
160
119
|
value = os.environ.get(key, "").strip()
|
|
161
|
-
|
|
162
|
-
path
|
|
163
|
-
if path.exists():
|
|
164
|
-
candidates.append((path, f"${key}"))
|
|
120
|
+
for path in possible_workspace_paths({"root": value}):
|
|
121
|
+
candidates.append((path, f"${key}"))
|
|
165
122
|
candidates.append((cwd, "process cwd"))
|
|
166
|
-
|
|
167
|
-
agent_id = hook_agent_id(payload) if event in {"subagent-start", "subagent-stop"} else ""
|
|
168
|
-
control_root = registered_subagent_control_root(payload, cwd, agent_id)
|
|
169
|
-
if control_root:
|
|
170
|
-
assigned = resolve_registered_subagent_root(control_root, agent_id)
|
|
171
|
-
if assigned and not is_context_guard_skill_path(assigned):
|
|
172
|
-
return assigned, f"registered subagent assignment ({agent_id})"
|
|
173
|
-
|
|
174
123
|
for path, source in candidates:
|
|
175
|
-
root =
|
|
176
|
-
if not is_context_guard_skill_path(root):
|
|
124
|
+
root = folder_root(path)
|
|
125
|
+
if not is_context_guard_skill_path(root) or initialized_source_project(root):
|
|
177
126
|
return root, source
|
|
178
|
-
|
|
179
|
-
root = git_root(cwd)
|
|
180
|
-
return root, "process cwd"
|
|
127
|
+
return folder_root(cwd), "process cwd"
|
|
181
128
|
|
|
182
129
|
|
|
183
130
|
def read_stdin() -> str:
|
|
184
131
|
try:
|
|
185
|
-
return sys.stdin.read()
|
|
132
|
+
return sys.stdin.buffer.read().decode("utf-8", errors="replace")
|
|
186
133
|
except Exception:
|
|
187
|
-
|
|
134
|
+
try:
|
|
135
|
+
return sys.stdin.read()
|
|
136
|
+
except Exception:
|
|
137
|
+
return ""
|
|
188
138
|
|
|
189
139
|
|
|
190
140
|
def hook_log(message: str) -> None:
|
|
191
141
|
print(message, file=sys.stderr)
|
|
192
142
|
|
|
193
143
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
144
|
+
HOOK_EVENT_NAMES = {
|
|
145
|
+
"session-start": "SessionStart",
|
|
146
|
+
"subagent-start": "SubagentStart",
|
|
147
|
+
"user-prompt-submit": "UserPromptSubmit",
|
|
148
|
+
"stop": "Stop",
|
|
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",
|
|
159
|
+
}
|
|
197
160
|
|
|
198
161
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
162
|
+
def hook_response(platform: str, event: str, additional_context: str = "") -> int:
|
|
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
|
|
167
|
+
if additional_context:
|
|
168
|
+
if platform == "cursor":
|
|
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
|
|
174
|
+
else:
|
|
175
|
+
payload["hookSpecificOutput"] = {
|
|
176
|
+
"hookEventName": HOOK_EVENT_NAMES.get(event, event),
|
|
177
|
+
"additionalContext": additional_context,
|
|
178
|
+
}
|
|
179
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
180
|
+
return 0
|
|
206
181
|
|
|
207
182
|
|
|
208
|
-
def
|
|
209
|
-
|
|
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
|
|
210
197
|
|
|
211
198
|
|
|
212
|
-
def
|
|
213
|
-
|
|
199
|
+
def payload_value(payload: object, keys: tuple[str, ...]) -> str:
|
|
200
|
+
if not isinstance(payload, dict):
|
|
201
|
+
return ""
|
|
202
|
+
for key in keys:
|
|
203
|
+
value = payload.get(key)
|
|
204
|
+
if isinstance(value, str) and value.strip():
|
|
205
|
+
return value.strip()
|
|
206
|
+
return ""
|
|
207
|
+
|
|
208
|
+
|
|
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)
|
|
214
461
|
|
|
215
462
|
|
|
216
|
-
def
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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:
|
|
495
|
+
value = payload_value(
|
|
496
|
+
payload,
|
|
497
|
+
("session_id", "sessionId", "conversation_id", "conversationId", "generation_id"),
|
|
498
|
+
)
|
|
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 ""
|
|
225
527
|
|
|
226
528
|
|
|
227
|
-
def
|
|
228
|
-
|
|
229
|
-
|
|
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 ""
|
|
230
592
|
try:
|
|
231
|
-
|
|
232
|
-
|
|
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):
|
|
233
610
|
pass
|
|
234
|
-
|
|
611
|
+
return ""
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def language_setup_context(root: Path, ctx: Path) -> str:
|
|
235
615
|
try:
|
|
236
|
-
|
|
237
|
-
except
|
|
238
|
-
|
|
239
|
-
if
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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."
|
|
619
|
+
if language and language != "unset":
|
|
620
|
+
return ""
|
|
621
|
+
quoted_root = '"' + str(root).replace('"', '\\"') + '"'
|
|
622
|
+
cli = context_guard_cli()
|
|
623
|
+
return (
|
|
624
|
+
"Context Guard language unset: ask 中文 or English. "
|
|
625
|
+
f"After the user answers, run `{cli} set-language --root "
|
|
626
|
+
f"{quoted_root} --language <zh-or-en>` and then continue in that language."
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
|
|
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"
|
|
637
|
+
workbench = f" Workbench: {workbench_url}." if workbench_url else ""
|
|
638
|
+
return (
|
|
639
|
+
f"Context Guard: {root}; Session: {current_session_id}.{workbench} "
|
|
640
|
+
f"Instructions: {skill}. CLI: {context_guard_cli()}."
|
|
250
641
|
)
|
|
251
|
-
|
|
642
|
+
|
|
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 ""
|
|
252
649
|
try:
|
|
253
|
-
|
|
254
|
-
|
|
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:
|
|
255
655
|
pass
|
|
256
|
-
return
|
|
656
|
+
return local_url
|
|
257
657
|
|
|
258
658
|
|
|
259
|
-
def
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
return "skipped", "empty prompt"
|
|
263
|
-
init_context(ctx.parent.parent)
|
|
264
|
-
path = ctx / "user-messages.md"
|
|
265
|
-
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
266
|
-
is_large = len(clean) > 1600 or clean.count("\n") > 30
|
|
267
|
-
ephemeral = has_ephemeral_secret(clean)
|
|
268
|
-
secret = has_secret(clean)
|
|
269
|
-
redacted = redact_user_message(clean)
|
|
270
|
-
secret_ref = ""
|
|
271
|
-
if secret and not ephemeral and len(clean) <= 4000:
|
|
272
|
-
secret_ref = write_private_secret(ctx, clean, redacted)
|
|
273
|
-
if is_large:
|
|
274
|
-
first_line = redacted.splitlines()[0][:240]
|
|
275
|
-
stored = f"Large user message or attachment; first line: {first_line}"
|
|
276
|
-
mode = "summary"
|
|
277
|
-
else:
|
|
278
|
-
stored = redacted
|
|
279
|
-
mode = "verbatim" if not secret and not ephemeral else "redacted"
|
|
280
|
-
if ephemeral:
|
|
281
|
-
secret_ref = "ephemeral-not-stored"
|
|
282
|
-
mode = "ephemeral"
|
|
283
|
-
body = path.read_text(encoding="utf-8") if path.exists() else "# User Message Memory\n\n## Recent User Signals\n\n"
|
|
284
|
-
if stored and stored in body[-4000:]:
|
|
285
|
-
return "skipped", "duplicate recent message"
|
|
286
|
-
entry = [
|
|
287
|
-
f"\n### {now}",
|
|
288
|
-
f"- Mode: {mode}",
|
|
289
|
-
f"- User message: {stored}",
|
|
290
|
-
]
|
|
291
|
-
if secret_ref:
|
|
292
|
-
entry.append(f"- Secret pointer: {secret_ref}")
|
|
293
|
-
entry.append("- Use: Preserve this wording when deciding task direction, constraints, credentials, preferences, bad-case intake, or roadmap `User request` fields.")
|
|
294
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
295
|
-
with path.open("a", encoding="utf-8") as handle:
|
|
296
|
-
handle.write("\n".join(entry) + "\n")
|
|
297
|
-
return "recorded", mode
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
def run_test_hub_completion(root: Path) -> tuple[int, str]:
|
|
301
|
-
ctx = context_dir(root)
|
|
302
|
-
tests = approved_dev_completion_tests(ctx)
|
|
303
|
-
if not tests:
|
|
304
|
-
return 0, "[context-guard] test hub: no approved every-dev-completion tests."
|
|
305
|
-
|
|
306
|
-
hook_log(f"[context-guard] test hub: running {len(tests)} approved every-dev-completion test(s).")
|
|
307
|
-
completed = subprocess.run(
|
|
308
|
-
[
|
|
309
|
-
sys.executable,
|
|
310
|
-
str(SKILL_ROOT / "scripts" / "context_guard.py"),
|
|
311
|
-
"dev-complete",
|
|
312
|
-
"--root",
|
|
313
|
-
str(root),
|
|
314
|
-
],
|
|
315
|
-
text=True,
|
|
316
|
-
capture_output=True,
|
|
317
|
-
timeout=900,
|
|
318
|
-
)
|
|
319
|
-
output = "\n".join(part for part in [completed.stdout.strip(), completed.stderr.strip()] if part)
|
|
320
|
-
return completed.returncode, output
|
|
659
|
+
def prompt_text(raw: str) -> str:
|
|
660
|
+
payload = parse_hook_payload(raw)
|
|
661
|
+
return payload_value(payload, ("prompt", "user_prompt", "userPrompt", "text", "content"))
|
|
321
662
|
|
|
322
663
|
|
|
323
|
-
def
|
|
324
|
-
if
|
|
325
|
-
return
|
|
326
|
-
|
|
327
|
-
if "[context-guard] test hub:" in line and " passed," in line and " failed," in line and " blocked" in line:
|
|
328
|
-
summary = line.split("test hub:", 1)[1].strip().rstrip(".")
|
|
329
|
-
if code == 0:
|
|
330
|
-
return f"all approved tests passed ({summary})"
|
|
331
|
-
return f"approved tests are not all passing ({summary})"
|
|
332
|
-
return "test hub status unknown; inspect `.codex/context/test-hub/last-run.json`"
|
|
664
|
+
def redact(text: str) -> str:
|
|
665
|
+
if len(text) > 800:
|
|
666
|
+
return text[:400].rstrip() + "…"
|
|
667
|
+
return text
|
|
333
668
|
|
|
334
669
|
|
|
335
|
-
def
|
|
336
|
-
|
|
670
|
+
def append_user_message(ctx: Path, text: str, current_session_id: str) -> str:
|
|
671
|
+
text = (text or "").strip()
|
|
672
|
+
if not text:
|
|
673
|
+
return "empty"
|
|
674
|
+
ctx.mkdir(parents=True, exist_ok=True)
|
|
675
|
+
path = ctx / "user-messages.md"
|
|
337
676
|
if not path.exists():
|
|
338
|
-
|
|
677
|
+
path.write_text("# User Message Memory\n\n## Recent User Signals\n\n", encoding="utf-8")
|
|
678
|
+
body = path.read_text(encoding="utf-8")
|
|
679
|
+
line = "- " + redact(text).replace("\n", " ")
|
|
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)])
|
|
339
720
|
try:
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
if not
|
|
352
|
-
|
|
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)
|
|
353
767
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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()
|
|
361
780
|
try:
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
if len(not_passing) > limit:
|
|
368
|
-
pieces.append(f"+{len(not_passing) - limit} more; inspect `.codex/context/test-hub/last-run.json`")
|
|
369
|
-
return "; ".join(pieces)
|
|
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())
|
|
370
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)
|
|
371
796
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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 []
|
|
375
811
|
try:
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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())
|
|
380
854
|
|
|
381
855
|
def walk(value: object) -> None:
|
|
382
|
-
if isinstance(value,
|
|
383
|
-
|
|
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)
|
|
384
861
|
elif isinstance(value, list):
|
|
385
|
-
for
|
|
386
|
-
walk(
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
"
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
"
|
|
487
|
-
"
|
|
488
|
-
"
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
"
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
"
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
"
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
"服务器",
|
|
552
|
-
"goal 模式",
|
|
553
|
-
"目标模式",
|
|
554
|
-
"长期",
|
|
555
|
-
]
|
|
556
|
-
return any(marker in lowered for marker in opportunity_markers)
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
def looks_like_explicit_branch(text: str) -> bool:
|
|
560
|
-
lowered = text.lower()
|
|
561
|
-
markers = [
|
|
562
|
-
"branch task",
|
|
563
|
-
"side task",
|
|
564
|
-
"side route",
|
|
565
|
-
"fork this",
|
|
566
|
-
"create a branch",
|
|
567
|
-
"new branch",
|
|
568
|
-
"as a branch",
|
|
569
|
-
"支线",
|
|
570
|
-
"分支",
|
|
571
|
-
"开一个分支",
|
|
572
|
-
"开一条支线",
|
|
573
|
-
"创建支线",
|
|
574
|
-
"创建分支",
|
|
575
|
-
"作为支线",
|
|
576
|
-
]
|
|
577
|
-
return any(marker in lowered for marker in markers)
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
def looks_like_route_drift(text: str) -> bool:
|
|
581
|
-
lowered = text.lower()
|
|
582
|
-
drift_markers = [
|
|
583
|
-
"significantly diverge",
|
|
584
|
-
"diverge from",
|
|
585
|
-
"different architecture",
|
|
586
|
-
"new architecture",
|
|
587
|
-
"new direction",
|
|
588
|
-
"refactor direction",
|
|
589
|
-
"偏离",
|
|
590
|
-
"显著偏离",
|
|
591
|
-
"新的架构",
|
|
592
|
-
"新方向",
|
|
593
|
-
"重构方向",
|
|
594
|
-
"主线架构",
|
|
595
|
-
]
|
|
596
|
-
return any(marker in lowered for marker in drift_markers)
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
def bad_case_blocks(text: str) -> list[dict[str, str]]:
|
|
600
|
-
blocks: list[dict[str, str]] = []
|
|
601
|
-
current: dict[str, str] | None = None
|
|
602
|
-
for line in text.splitlines():
|
|
603
|
-
heading = line.startswith("### BC-")
|
|
604
|
-
if heading:
|
|
605
|
-
if current:
|
|
606
|
-
blocks.append(current)
|
|
607
|
-
identifier, _, title = line.removeprefix("### ").partition(":")
|
|
608
|
-
current = {"id": identifier.strip(), "title": title.strip()}
|
|
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
|
|
609
1028
|
continue
|
|
610
|
-
if
|
|
1029
|
+
if re.fullmatch(r"(?:\d*)>&\d+", token):
|
|
1030
|
+
index += 1
|
|
611
1031
|
continue
|
|
612
|
-
|
|
613
|
-
|
|
1032
|
+
if "&" in token:
|
|
1033
|
+
return None
|
|
1034
|
+
current.append(token)
|
|
1035
|
+
index += 1
|
|
614
1036
|
if current:
|
|
615
|
-
|
|
616
|
-
return
|
|
1037
|
+
segments.append(current)
|
|
1038
|
+
return segments if segments else None
|
|
617
1039
|
|
|
618
1040
|
|
|
619
|
-
def
|
|
620
|
-
if
|
|
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):
|
|
621
1274
|
return []
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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))
|
|
629
1343
|
|
|
630
1344
|
|
|
631
|
-
def
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
|
1597
|
+
else:
|
|
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
|
|
646
1632
|
|
|
647
1633
|
|
|
648
1634
|
def main() -> int:
|
|
649
|
-
|
|
1635
|
+
configure_stdio()
|
|
1636
|
+
parser = argparse.ArgumentParser(description="Context Guard hook adapter")
|
|
1637
|
+
parser.add_argument("event", nargs="?", default="unknown")
|
|
1638
|
+
parser.add_argument("--platform", choices=["codex", "cursor", "claude"], default="codex")
|
|
1639
|
+
args, _unknown = parser.parse_known_args()
|
|
1640
|
+
event = args.event
|
|
1641
|
+
platform = args.platform
|
|
650
1642
|
raw = read_stdin()
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
if event == "user-prompt-submit":
|
|
680
|
-
record_status, record_mode = append_user_message(context_dir, text)
|
|
681
|
-
hints: list[str] = []
|
|
682
|
-
if record_status == "recorded":
|
|
683
|
-
if record_mode == "redacted":
|
|
684
|
-
hints.append("user message memory: saved latest prompt with secrets redacted; raw secret, if durable, is local-only under `.codex/context/private/`")
|
|
685
|
-
elif record_mode == "ephemeral":
|
|
686
|
-
hints.append("user message memory: saved latest prompt with one-time code redacted; raw ephemeral code was not persisted")
|
|
687
|
-
elif record_mode == "summary":
|
|
688
|
-
hints.append("user message memory: saved a concise summary of the latest large prompt instead of copying the full blob")
|
|
689
|
-
else:
|
|
690
|
-
hints.append("user message memory: saved latest short user prompt in `.codex/context/user-messages.md`")
|
|
691
|
-
else:
|
|
692
|
-
hints.append(f"user message memory: skipped ({record_mode})")
|
|
693
|
-
if looks_like_goal_mode(text):
|
|
694
|
-
hints.append("goal mode: align active goal with current context and record roadmap/bad-case checkpoints during long-running work")
|
|
695
|
-
if looks_like_remote_work(text):
|
|
696
|
-
hints.append("remote/SSH work: keep `.codex/context` in the local Codex workspace; record remote host/path as metadata and do not initialize roadmap context on the server unless explicitly requested")
|
|
697
|
-
if looks_like_test_creation(text):
|
|
698
|
-
hints.append("explicit test creation: start the user-visible response with `测试创建识别:...`, summarize the test target from state A to state B, and only create durable tests after the user's design is clear or confirmed")
|
|
699
|
-
elif looks_like_test_opportunity(text):
|
|
700
|
-
hints.append("test opportunity: if this task changes a reusable workflow, fixes a recurring/user-visible bug, or is likely to regress, gently ask whether the user wants to create a test task; keep it optional and do not create durable tests without approval")
|
|
701
|
-
if looks_like_explicit_branch(text):
|
|
702
|
-
hints.append("explicit branch task: create/select a branch task by running `context_guard.py create-branch-task --title <task title> --branch <branch name> --parent-node <parent NODE id>` before implementation; verify the roadmap node has Branch: and Parent:")
|
|
703
|
-
elif looks_like_route_drift(text):
|
|
704
|
-
hints.append("possible route drift: ask whether to create a branch before moving away from the current mainline architecture")
|
|
705
|
-
if looks_like_task_switch(text):
|
|
706
|
-
hints.append("possible task switch: park current context in .codex/context/index.md before switching")
|
|
707
|
-
if looks_like_bad_case(text):
|
|
708
|
-
hints.append("possible bad case: record/update .codex/context/bad-cases.md or task-local bad-cases.md")
|
|
709
|
-
if not hints:
|
|
710
|
-
hints.append("run Context Guard intake: continue current context or note no active context")
|
|
711
|
-
hook_log("[context-guard] " + "; ".join(hints))
|
|
712
|
-
hook_log(f"[context-guard] root source: {root_source}")
|
|
713
|
-
hook_log(f"[context-guard] project root: {root}")
|
|
714
|
-
hook_log(f"[context-guard] context folder: {context_dir}")
|
|
715
|
-
hook_log(f"[context-guard] context index: {index_path}")
|
|
716
|
-
hook_log(f"[context-guard] route map: {roadmap_path}")
|
|
717
|
-
return hook_response()
|
|
718
|
-
|
|
719
|
-
if event in {"stop", "subagent-stop"}:
|
|
720
|
-
lifecycle_label = "SubagentStop" if event == "subagent-stop" else "Stop"
|
|
721
|
-
hook_log(f"[context-guard] {lifecycle_label} checkpoint: update index, route map nodes, parked/resume tasks, and relevant bad-case/test-chain links before finalizing.")
|
|
722
|
-
hook_log("[context-guard] COMPLETION RELIABILITY GATE: use existing user screenshots/logs/reproductions as red evidence when available; implement once the cause is clear, then run the smallest real post-fix check. Default budget is one primary check plus at most two highly relevant bad-case guards.")
|
|
723
|
-
hook_log("[context-guard] BAD-CASE GUARD GATE: newly checked resolved or recurred BC entries need Guard type, Red condition, Green condition, Expected failure reason, and a red-capable Guard / verification; run `context_guard.py validate-bad-cases` only after register/schema/renderer edits, or `--strict` when intentionally migrating/checking all resolved cases.")
|
|
724
|
-
hook_log("[context-guard] GUARD SELECTION GATE: do not run every historical guard and do not manufacture new red tests when credible evidence already exists. Select guards by changed files, feature area, route branch, tags, and original user-visible symptom; skip unrelated resolved cases.")
|
|
725
|
-
hook_log("[context-guard] TEST HUB GATE: Stop/SubagentStop hooks run `context_guard.py dev-complete --root <project>` so the hub executes every human-approved `every-dev-completion` test, cleans success artifacts, and preserves failed/blocked evidence. Do not treat ordinary bad-case guards or roadmap Test chain notes as registered tests.")
|
|
726
|
-
hook_log("[context-guard] TASK-CASE GATE: when a workflow has multiple phases, prefer one relevant task case from `.codex/context/task-cases/` with phase/checkpoint logs over many isolated bug-level tests; report the failed phase/checkpoint if it breaks.")
|
|
727
|
-
hook_log("[context-guard] TEST BLOCKER GATE: if approved tests are blocked by credentials, external service outage, permission denial, hardware/resource limits, network, destructive-risk confirmation, or user-only judgment, stop and ask/warn the user with the exact blocker and evidence path.")
|
|
728
|
-
hook_log("[context-guard] TASK-CASE DESIGN GATE: before writing a new durable task-case script for a complex workflow, ask the user to confirm a short business-facing proposal: from what state to what state, main task, and major risk; keep technical details inside the task-case file, or keep it `proposed` if unavailable.")
|
|
729
|
-
hook_log("[context-guard] TEST OPPORTUNITY GATE: if this turn changed a reusable workflow, fixed a recurring/user-visible bug, or created a phase that is likely to regress, include a brief optional nudge asking whether the user wants to create a test task. Do not create durable tests unless the user confirms.")
|
|
730
|
-
hook_log("[context-guard] GOAL-MODE TEST GATE: in goal mode, use task cases as phase gates; log current phase progress and run the smallest approved path before claiming goal completion instead of silently creating broad new tests.")
|
|
731
|
-
hook_log("[context-guard] ROADMAP CHECKPOINT GATE: assess whether this turn deserves a roadmap node. Create one only for meaningful progress, a route decision, a fix, a branch/fork, a user-visible milestone, or stale hidden checkpoints; otherwise say no roadmap node was needed and why.")
|
|
732
|
-
hook_log("[context-guard] If a node is needed, run `context_guard.py checkpoint-roadmap-node --title <short title> --branch <Main or route> --level <major|checkpoint> --outcome <one-line progress> --next-step <next>` and include linked BC/test-chain notes when relevant.")
|
|
733
|
-
hook_log("[context-guard] ROADMAP MAINTENANCE GATE: run `context_guard.py validate-roadmap-maintenance` after route updates; do not let mainline/branch overview stay stale while important work is hidden as checkpoints.")
|
|
734
|
-
hook_log("[context-guard] If frontend/UI/HTML/CSS/layout/browser behavior changed, inspect with browser/screenshot or state the exact blocker; do not claim fixed without this evidence.")
|
|
735
|
-
hook_log("[context-guard] Branch task gate: if the user explicitly asked for a branch, ensure `context_guard.py create-branch-task --title <task title> --branch <branch name> --parent-node <parent NODE id>` has created the task folder, index current entry, and Branch/Parent roadmap node; if the work significantly drifts from the mainline architecture, ask whether to create a branch before finalizing.")
|
|
736
|
-
hook_log("[context-guard] final answer must include verification evidence and must not say done/fixed/passing unless the gate above was satisfied.")
|
|
737
|
-
hook_log(f"[context-guard] root source: {root_source}")
|
|
738
|
-
hook_log(f"[context-guard] project root: {root}")
|
|
739
|
-
hook_log(f"[context-guard] context folder: {context_dir}")
|
|
740
|
-
hook_log(f"[context-guard] bad-case register: {bad_cases_path}")
|
|
741
|
-
if event == "subagent-stop":
|
|
742
|
-
payload = parse_hook_payload(raw)
|
|
743
|
-
agent_id = hook_agent_id(payload)
|
|
744
|
-
last_message = hook_text_field(payload, "last_assistant_message")
|
|
745
|
-
control_root = registered_subagent_control_root(payload, Path.cwd(), agent_id)
|
|
746
|
-
command_root = control_root or root
|
|
747
|
-
command = [
|
|
748
|
-
sys.executable,
|
|
749
|
-
str(SKILL_ROOT / "scripts" / "context_guard.py"),
|
|
750
|
-
"subagent-complete",
|
|
751
|
-
"--root",
|
|
752
|
-
str(command_root),
|
|
753
|
-
"--agent-id",
|
|
754
|
-
agent_id,
|
|
755
|
-
"--summary",
|
|
756
|
-
last_message,
|
|
757
|
-
]
|
|
1643
|
+
payload = parse_hook_payload(raw)
|
|
1644
|
+
root, root_source = event_root(raw, Path.cwd())
|
|
1645
|
+
ctx = context_folder(root)
|
|
1646
|
+
|
|
1647
|
+
if is_context_guard_skill_path(root) and not initialized_source_project(root):
|
|
1648
|
+
hook_log("[context-guard] apparent root is the skill directory; skipping writes.")
|
|
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 = ""
|
|
1654
|
+
|
|
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
|
|
758
1671
|
try:
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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.",
|
|
765
1735
|
)
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
return hook_response(decision="block", reason=reason)
|
|
790
|
-
open_cases = unresolved_bad_cases(bad_cases_path)
|
|
791
|
-
hook_log("[context-guard] final answer must include BC summary: archived/updated BC this turn, and current unresolved BC.")
|
|
792
|
-
hook_log(f"[context-guard] current unresolved BC: {format_unresolved_bad_cases(open_cases)}")
|
|
793
|
-
return hook_response()
|
|
794
|
-
try:
|
|
795
|
-
test_code, test_output = run_test_hub_completion(root)
|
|
796
|
-
for line in test_output.splitlines():
|
|
797
|
-
hook_log(line)
|
|
798
|
-
hook_log(
|
|
799
|
-
"[context-guard] final answer must include Test Hub summary: "
|
|
800
|
-
+ completion_test_summary(test_output, test_code)
|
|
801
|
-
)
|
|
802
|
-
if test_code != 0:
|
|
803
|
-
details = completion_test_failure_details(root)
|
|
804
|
-
if details:
|
|
805
|
-
hook_log("[context-guard] failing approved test details: " + details)
|
|
806
|
-
reason = (
|
|
807
|
-
"Context Guard Test Hub found failed or blocked approved tests. "
|
|
808
|
-
"Read `.codex/context/test-hub/last-run.json` and preserved run evidence, "
|
|
809
|
-
"then fix or report the blocker before finalizing."
|
|
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.",
|
|
810
1759
|
)
|
|
811
|
-
if details:
|
|
812
|
-
reason += " Failing tests: " + details
|
|
813
|
-
hook_log(f"[context-guard] TEST HUB BLOCKER: {reason}")
|
|
814
|
-
return hook_response(decision="block", reason=reason)
|
|
815
|
-
except subprocess.TimeoutExpired:
|
|
816
|
-
reason = "Context Guard Test Hub timed out after 900s; report this blocker and evidence before finalizing."
|
|
817
|
-
hook_log(f"[context-guard] TEST HUB BLOCKER: {reason}")
|
|
818
|
-
return hook_response(decision="block", reason=reason)
|
|
819
|
-
except Exception as exc:
|
|
820
|
-
hook_log(f"[context-guard] test hub hook warning: {exc}")
|
|
821
1760
|
try:
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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)]
|
|
1814
|
+
playbook = ctx / "tasks" / "J2.md"
|
|
1815
|
+
if playbook.is_file():
|
|
1816
|
+
contexts.append(
|
|
1817
|
+
"Repository development playbook: read .codex/context/tasks/J2.md "
|
|
1818
|
+
"for the current product and testing branch rules."
|
|
835
1819
|
)
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1820
|
+
return hook_response(platform, event, "\n\n".join(item for item in contexts if item))
|
|
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
|
+
|
|
1963
|
+
if event == "user-prompt-submit":
|
|
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)
|
|
1973
|
+
append_session_event(
|
|
1974
|
+
root,
|
|
1975
|
+
event,
|
|
1976
|
+
platform,
|
|
1977
|
+
current_session_id,
|
|
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
|
+
})),
|
|
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."
|
|
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"})))
|
|
2018
|
+
return hook_response(platform, event)
|
|
2019
|
+
|
|
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
|
+
)))
|
|
2054
|
+
return hook_response(platform, event)
|
|
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
|
+
|
|
2098
|
+
hook_log(f"[context-guard] ignored event: {event}")
|
|
2099
|
+
return hook_response(platform, event)
|
|
851
2100
|
|
|
852
2101
|
|
|
853
2102
|
if __name__ == "__main__":
|