semgate 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. semgate/__init__.py +10 -0
  2. semgate/__main__.py +3 -0
  3. semgate/action_identity.py +64 -0
  4. semgate/adapters/__init__.py +0 -0
  5. semgate/adapters/antigravity.py +419 -0
  6. semgate/adapters/claude_family.py +419 -0
  7. semgate/adapters/codex.py +154 -0
  8. semgate/adapters/opencode.py +73 -0
  9. semgate/adapters/opencode_tool.py +435 -0
  10. semgate/adapters/pi.py +121 -0
  11. semgate/adminguard.py +314 -0
  12. semgate/agentfiles.py +298 -0
  13. semgate/antigravity_hook.py +738 -0
  14. semgate/antigravity_post_hook.py +42 -0
  15. semgate/approvals.py +221 -0
  16. semgate/assets/__init__.py +1 -0
  17. semgate/assets/opencode_semgate.js +309 -0
  18. semgate/assets/pi_semgate.ts +125 -0
  19. semgate/assets/semgate_skill.md +77 -0
  20. semgate/calibration.py +259 -0
  21. semgate/capabilities.py +36 -0
  22. semgate/chatapproval.py +842 -0
  23. semgate/claude_hook.py +221 -0
  24. semgate/cli.py +645 -0
  25. semgate/client.py +221 -0
  26. semgate/codesignals.py +1702 -0
  27. semgate/codestamp.py +223 -0
  28. semgate/data/approve_request.schema.json +16 -0
  29. semgate/data/check_request.schema.json +43 -0
  30. semgate/data/check_response.schema.json +19 -0
  31. semgate/data/demo_recorded.json +306 -0
  32. semgate/data/hosts/antigravity.json +36 -0
  33. semgate/data/hosts/claude.json +43 -0
  34. semgate/data/hosts/codex.json +43 -0
  35. semgate/data/hosts/droid.json +36 -0
  36. semgate/data/hosts/opencode-v1.json +42 -0
  37. semgate/data/hosts/opencode-v2.json +42 -0
  38. semgate/data/hosts/pi.json +42 -0
  39. semgate/data/model_limits.json +29 -0
  40. semgate/demo.py +355 -0
  41. semgate/doctor.py +207 -0
  42. semgate/enforcement.py +137 -0
  43. semgate/envelope.py +347 -0
  44. semgate/eval/__init__.py +5 -0
  45. semgate/eval/case.py +79 -0
  46. semgate/eval/chat_approval.py +472 -0
  47. semgate/eval/exposure_intent.py +157 -0
  48. semgate/eval/importers.py +82 -0
  49. semgate/eval/metrics.py +61 -0
  50. semgate/eval/runner.py +144 -0
  51. semgate/eval/trust_pin.py +303 -0
  52. semgate/evidence.py +30 -0
  53. semgate/exposures.py +628 -0
  54. semgate/extractor.py +286 -0
  55. semgate/feedback.py +272 -0
  56. semgate/filelock.py +366 -0
  57. semgate/fingerprints.py +151 -0
  58. semgate/gate.py +218 -0
  59. semgate/gemini_gate.py +314 -0
  60. semgate/gitstate.py +384 -0
  61. semgate/harness.py +615 -0
  62. semgate/harnesstools.py +216 -0
  63. semgate/history.py +133 -0
  64. semgate/hookinput.py +267 -0
  65. semgate/hosts/__init__.py +57 -0
  66. semgate/hosts/base.py +338 -0
  67. semgate/hosts/builtin.py +1191 -0
  68. semgate/hosts/installed.py +62 -0
  69. semgate/hosts/pitrust.py +142 -0
  70. semgate/httpserve.py +573 -0
  71. semgate/init_antigravity.py +572 -0
  72. semgate/injection.py +728 -0
  73. semgate/judge.py +758 -0
  74. semgate/ledger.py +258 -0
  75. semgate/linkplace.py +1346 -0
  76. semgate/ownmessages.py +434 -0
  77. semgate/payloadsize.py +112 -0
  78. semgate/pingate.py +463 -0
  79. semgate/pins.py +566 -0
  80. semgate/policies/__init__.py +3 -0
  81. semgate/policies/default_policy.json +87 -0
  82. semgate/policies/known-benign-capabilities.json +5 -0
  83. semgate/policies/profiles.json +69 -0
  84. semgate/policies/purpose_grant_policy.json +58 -0
  85. semgate/policies/router_policy.json +48 -0
  86. semgate/policies/router_policy_dev.json +187 -0
  87. semgate/policies/router_policy_dev_all.json +126 -0
  88. semgate/policies/router_policy_dev_all_no_crit.json +114 -0
  89. semgate/policies/router_policy_dev_all_no_lastcheck.json +125 -0
  90. semgate/policies/router_policy_dev_all_no_s3.json +125 -0
  91. semgate/policies/router_policy_dev_all_no_s4.json +125 -0
  92. semgate/policies/router_policy_dev_all_no_sdrift.json +124 -0
  93. semgate/policies/router_policy_dev_buildfacts.json +176 -0
  94. semgate/policies/router_policy_dev_chatapprove.json +152 -0
  95. semgate/policies/router_policy_dev_chatdecline.json +185 -0
  96. semgate/policies/router_policy_dev_crit.json +113 -0
  97. semgate/policies/router_policy_dev_exposure.json +136 -0
  98. semgate/policies/router_policy_dev_final.json +125 -0
  99. semgate/policies/router_policy_dev_g1.json +99 -0
  100. semgate/policies/router_policy_dev_g12.json +100 -0
  101. semgate/policies/router_policy_dev_g12b.json +101 -0
  102. semgate/policies/router_policy_dev_g2.json +99 -0
  103. semgate/policies/router_policy_dev_pinq.json +175 -0
  104. semgate/policies/router_policy_dev_s3.json +105 -0
  105. semgate/policies/router_policy_dev_s3f.json +106 -0
  106. semgate/policies/router_policy_dev_s4.json +105 -0
  107. semgate/policies/router_policy_dev_s4allow.json +177 -0
  108. semgate/policies/router_policy_dev_s5.json +124 -0
  109. semgate/policies/router_policy_dev_s6.json +151 -0
  110. semgate/policies/router_policy_dev_sdrift.json +103 -0
  111. semgate/policies/router_policy_dev_testrun.json +175 -0
  112. semgate/policies/router_policy_dev_trust.json +169 -0
  113. semgate/policies/router_policy_dev_turns.json +106 -0
  114. semgate/policies/router_policy_f7.json +105 -0
  115. semgate/policies/router_policy_trace.json +66 -0
  116. semgate/policies/router_policy_v2.json +51 -0
  117. semgate/policies/router_policy_v3.json +67 -0
  118. semgate/policies/router_policy_v3c.json +69 -0
  119. semgate/policies/router_policy_v3c_lite.json +69 -0
  120. semgate/policy.py +96 -0
  121. semgate/predicates.py +43 -0
  122. semgate/proc.py +23 -0
  123. semgate/profiles.py +298 -0
  124. semgate/providers/__init__.py +4 -0
  125. semgate/providers/base.py +44 -0
  126. semgate/providers/common.py +129 -0
  127. semgate/providers/fake.py +61 -0
  128. semgate/providers/keys.py +147 -0
  129. semgate/providers/openrouter.py +365 -0
  130. semgate/providers/recorded.py +153 -0
  131. semgate/providers/registry.py +57 -0
  132. semgate/providers/typesafe.py +171 -0
  133. semgate/replay.py +128 -0
  134. semgate/replay_offline.py +306 -0
  135. semgate/report.py +353 -0
  136. semgate/router.py +776 -0
  137. semgate/rules.py +1123 -0
  138. semgate/safemerge.py +606 -0
  139. semgate/scriptsource.py +494 -0
  140. semgate/secretfinder.py +415 -0
  141. semgate/serve.py +641 -0
  142. semgate/shellparse.py +535 -0
  143. semgate/skill.py +278 -0
  144. semgate/storepaths.py +194 -0
  145. semgate/telemetry.py +309 -0
  146. semgate/testrun.py +2416 -0
  147. semgate/tooloutputs.py +490 -0
  148. semgate/trust.py +914 -0
  149. semgate/trustauth.py +756 -0
  150. semgate/trustgate.py +496 -0
  151. semgate-0.4.0.dist-info/METADATA +1581 -0
  152. semgate-0.4.0.dist-info/RECORD +157 -0
  153. semgate-0.4.0.dist-info/WHEEL +5 -0
  154. semgate-0.4.0.dist-info/entry_points.txt +4 -0
  155. semgate-0.4.0.dist-info/licenses/LICENSE +202 -0
  156. semgate-0.4.0.dist-info/licenses/NOTICE +8 -0
  157. semgate-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,419 @@
1
+ """Adapter for hosts that use Claude Code's PreToolUse hook shape.
2
+
3
+ One adapter, five hosts (formats verified against each host's docs on
4
+ 2026-09-22):
5
+
6
+ host input output
7
+ claude tool_name/tool_input/transcript_path hookSpecificOutput.permissionDecision
8
+ droid same (Factory: Execute/Edit/Create) same
9
+ vscode same (run_in_terminal, create_file ...) same (VS Code also reads ~/.claude/settings.json)
10
+ copilot toolName/toolArgs (or the Claude form) top-level permissionDecision
11
+ devin tool_name/tool_input/prompt_id {"decision": "approve"|"block"} (no native ask)
12
+ (and none of tool_use_id / transcript_path / permission_mode: Claude Code
13
+ 2.1.281 also sends prompt_id; see CLAUDE_ONLY_FIELDS)
14
+
15
+ The event is untrusted data: only documented fields are copied into the
16
+ envelope, and the grant never comes from the event or the transcript.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import re
22
+ from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Sequence, Tuple
23
+
24
+ from ..envelope import (AGENT_INTENT_MAX, Envelope, Environment, ProposedAction, SCHEMA_VERSION, Trajectory, TrajectoryEntry,
25
+ UserGrant, bound_user_messages, short_result)
26
+ from ..harnesstools import HOST_NAMES
27
+
28
+ HOSTS = ("claude", "droid", "vscode", "copilot", "devin")
29
+
30
+ # host tool name (lowercased) -> semgate canonical tool
31
+ _TOOLS = {
32
+ # shell
33
+ "bash": "bash", "execute": "bash", "run_in_terminal": "bash", "exec": "bash", "powershell": "bash",
34
+ "shell": "bash", "run_command": "bash", "terminal": "bash",
35
+ # edits / writes
36
+ "edit": "edit", "multiedit": "edit", "replace_string_in_file": "edit", "multi_replace_string_in_file": "edit",
37
+ "insert_edit_into_file": "edit", "apply_patch": "edit", "notebookedit": "edit", "str_replace_based_edit_tool": "edit",
38
+ "write": "write", "create": "write", "create_file": "write",
39
+ # reads / search
40
+ "read": "read", "view": "read", "read_file": "read", "ls": "ls", "list_dir": "ls",
41
+ "glob": "glob", "file_search": "glob", "grep": "grep", "grep_search": "grep",
42
+ # web
43
+ "webfetch": "web_fetch", "fetch_webpage": "web_fetch", "fetch": "web_fetch", "websearch": "web_search",
44
+ # harness tools: ask the user, to-do list, load a skill (harnesstools.py)
45
+ **HOST_NAMES,
46
+ }
47
+ _PATH_KEYS = ("file_path", "filePath", "path", "notebook_path", "filename")
48
+ _OUTPUT_CAP = 6000
49
+ _HARNESS_TEXT = ("<command-", "<local-command", "<system-reminder", "<user-prompt-submit-hook", "Caveat:")
50
+
51
+
52
+ # Fields Claude Code sends and Devin CLI does not document. Claude Code
53
+ # 2.1.280 PreToolUse keys (hookconf probe, results/claude-code-2.1.280.json):
54
+ # cwd, effort, hook_event_name, permission_mode, prompt_id, session_id,
55
+ # tool_input, tool_name, tool_use_id, transcript_path (PostToolUse adds
56
+ # duration_ms, tool_response; UserPromptSubmit has transcript_path and
57
+ # permission_mode but no tool_use_id). Claude Code 2.1.281 also sends
58
+ # prompt_id (hookconf e2e 5d5714b answered it in Devin's format). Devin CLI
59
+ # documents only hook_event_name, tool_name, tool_input, session_id and a
60
+ # per-turn prompt_id (docs.devin.ai/cli/extensibility/hooks, read 2026-09-24).
61
+ # So prompt_id alone does not mean Devin.
62
+ CLAUDE_ONLY_FIELDS = ("tool_use_id", "transcript_path", "permission_mode")
63
+
64
+
65
+ def is_devin_shape(event: Mapping[str, Any]) -> bool:
66
+ """True when the event has Devin CLI's documented shape: prompt_id and
67
+ none of the fields only Claude Code sends."""
68
+ return "prompt_id" in event and not any(k in event for k in CLAUDE_ONLY_FIELDS)
69
+
70
+
71
+ def detect_host(event: Mapping[str, Any]) -> str:
72
+ if "toolName" in event or "toolArgs" in event:
73
+ return "copilot"
74
+ if is_devin_shape(event):
75
+ return "devin"
76
+ tool = str(event.get("tool_name", ""))
77
+ if tool in ("run_in_terminal", "create_file", "replace_string_in_file", "read_file") or tool.startswith("copilot_"):
78
+ return "vscode"
79
+ if ".factory" in str(event.get("transcript_path", "")) or tool in ("Execute", "Create"):
80
+ return "droid"
81
+ return "claude"
82
+
83
+
84
+ def resolve_host(requested: str, event: Mapping[str, Any]) -> str:
85
+ """The host to answer, from --host and the event. `auto`: detect_host.
86
+ `claude`: Claude Code, except an event with Devin CLI's shape, because
87
+ Devin CLI also runs the hooks in ~/.claude/settings.json and does not
88
+ document Claude's output (hookSpecificOutput.permissionDecision): a
89
+ Claude-format answer there could let a blocked call run. Devin's format
90
+ turns an ask into a block, the safe direction if the shape check is
91
+ wrong. Any other value is used as given."""
92
+ if requested == "auto":
93
+ return detect_host(event)
94
+ if requested == "claude" and is_devin_shape(event):
95
+ return "devin"
96
+ return requested
97
+
98
+
99
+ def host_decision(host: str, decision: str, reason: str) -> Tuple[str, str]:
100
+ """The decision the host actually gets, in semgate's allow/ask/deny, so
101
+ the ledger's host_response records it. Devin CLI has no ask: an ask is
102
+ sent as a block (a deny), never an approve."""
103
+ if host == "devin" and decision == "ask":
104
+ return "deny", ("semgate needs a human decision: " + reason)[:1000]
105
+ return decision, reason
106
+
107
+
108
+ def _tool_and_args(event: Mapping[str, Any]) -> Tuple[str, str, Dict[str, Any]]:
109
+ native = str(event.get("tool_name") or event.get("toolName") or "")
110
+ raw = event.get("tool_input") if "tool_input" in event else event.get("toolArgs")
111
+ if isinstance(raw, str):
112
+ try:
113
+ raw = json.loads(raw)
114
+ except ValueError:
115
+ raw = {"command": raw}
116
+ args = dict(raw) if isinstance(raw, Mapping) else {}
117
+ canonical = _TOOLS.get(native.lower(), native.lower() or "unknown")
118
+ if native.lower().startswith("mcp__"):
119
+ canonical = native # keep MCP tool identity (server + tool)
120
+ # canonical argument names the rules and router understand
121
+ if canonical == "bash" and "command" not in args:
122
+ for k in ("cmd", "commandLine", "CommandLine", "script"):
123
+ if isinstance(args.get(k), str):
124
+ args["command"] = args[k]
125
+ break
126
+ if "path" not in args:
127
+ for k in _PATH_KEYS:
128
+ if isinstance(args.get(k), str) and args[k]:
129
+ args["path"] = args[k]
130
+ break
131
+ if canonical == "web_fetch" and "url" not in args and isinstance(args.get("urls"), list) and args["urls"]:
132
+ args["url"] = str(args["urls"][0])
133
+ return native, canonical, args
134
+
135
+
136
+ def _text_of(content: Any) -> str:
137
+ if isinstance(content, str):
138
+ return content
139
+ if isinstance(content, list):
140
+ out = []
141
+ for part in content:
142
+ if isinstance(part, Mapping) and part.get("type") == "text":
143
+ out.append(str(part.get("text", "")))
144
+ elif isinstance(part, str):
145
+ out.append(part)
146
+ return "\n".join(out)
147
+ return ""
148
+
149
+
150
+ class TranscriptContext(NamedTuple):
151
+ users: List[str] # the user's own turns, oldest first
152
+ trace: Tuple[TrajectoryEntry, ...] # recent tool calls with output / result / files_changed
153
+ agent_intent: str # latest assistant text after the latest user turn ("" if none)
154
+ call_ids: Tuple[str, ...] = () # tool_use id of each trace entry (same order)
155
+ known_ids: frozenset = frozenset() # every tool_use id in the transcript (also older than the trace)
156
+
157
+
158
+ _EDIT_TOOLS = frozenset({"edit", "write"})
159
+ _EXIT_RE = re.compile(r"^\s*Exit code (-?\d+)\s*\n?")
160
+
161
+
162
+ def _result_of(canonical: str, part: Mapping[str, Any], text: str) -> str:
163
+ """TrajectoryEntry.result from a tool_result block. Claude Code marks a
164
+ failed call with is_error; a failed shell call's content starts with
165
+ "Exit code N" (verified on local Claude Code transcripts 2026-09-23). A
166
+ successful shell call carries no exit code, so it is reported as "ok"."""
167
+ is_error = bool(part.get("is_error"))
168
+ m = _EXIT_RE.match(text) if canonical == "bash" else None
169
+ if m:
170
+ return short_result(text[m.end():], exit_code=m.group(1))
171
+ return short_result(text, error=is_error) if (text.strip() or is_error) else ""
172
+
173
+
174
+ def _is_user_turn(entry: Mapping[str, Any], text: str) -> bool:
175
+ """The user's own text: not empty, not harness-injected text, not a
176
+ compaction summary, not an interruption notice. (Tool results, isMeta
177
+ and sidechain entries are left out before this check.)"""
178
+ return bool(text and not text.startswith(_HARNESS_TEXT) and not entry.get("isCompactSummary")
179
+ and not text.startswith("[Request interrupted"))
180
+
181
+
182
+ def chat_conversation(path: Optional[str]) -> Any:
183
+ """The transcript as ordered items for approval by chat reply
184
+ (chatapproval.Conversation), or None when it cannot be read. User items
185
+ follow read_transcript_context's rule exactly (tool_result blocks, meta,
186
+ sidechain, harness text, compaction summaries and interruption notices
187
+ are never user items), each with the entry's `timestamp` and `uuid`.
188
+ Agent items: assistant text blocks. Call items: assistant tool_use blocks
189
+ with their id (the PreToolUse event's tool_use_id)."""
190
+ from ..chatapproval import Conversation, Item
191
+ from ..gitstate import to_epoch
192
+ if not isinstance(path, str) or not path:
193
+ return None
194
+ items: List[Any] = []
195
+ try:
196
+ with open(path, encoding="utf-8") as handle:
197
+ for line in handle:
198
+ line = line.strip()
199
+ if not line:
200
+ continue
201
+ try:
202
+ entry = json.loads(line)
203
+ except ValueError:
204
+ continue
205
+ if not isinstance(entry, Mapping) or entry.get("isMeta") or entry.get("isSidechain"):
206
+ continue
207
+ msg = entry.get("message") if isinstance(entry.get("message"), Mapping) else entry
208
+ role = str(entry.get("type") or msg.get("role") or "")
209
+ content = msg.get("content")
210
+ ts = to_epoch(entry.get("timestamp"))
211
+ if role == "user":
212
+ texts = []
213
+ for part in (content if isinstance(content, list) else [content]):
214
+ if isinstance(part, Mapping) and part.get("type") == "text":
215
+ texts.append(str(part.get("text", "")))
216
+ elif isinstance(part, str):
217
+ texts.append(part)
218
+ text = "\n".join(t for t in texts if t).strip()
219
+ if _is_user_turn(entry, text):
220
+ items.append(Item("user", text, ts, msg_id=str(entry.get("uuid") or "")))
221
+ elif role == "assistant" and isinstance(content, list):
222
+ for part in content:
223
+ if isinstance(part, Mapping) and part.get("type") == "text" and str(part.get("text", "")).strip():
224
+ items.append(Item("agent", str(part.get("text", "")).strip(), ts))
225
+ elif isinstance(part, Mapping) and part.get("type") == "tool_use":
226
+ items.append(Item("call", str(part.get("name", "")), ts, call_id=str(part.get("id") or "")))
227
+ except OSError:
228
+ return None
229
+ return Conversation(tuple(items), complete=True, timestamps=True, source="claude transcript")
230
+
231
+
232
+ def read_transcript_context(path: Optional[str], current: str = "", current_id: str = "") -> TranscriptContext:
233
+ """Users, trajectory and agent intent from a Claude-Code-style JSONL transcript.
234
+
235
+ User messages are entries of type "user" whose content is the user's own
236
+ text (not tool results, not harness-injected meta text, not a compaction
237
+ summary, not an interruption notice). Tool calls come from assistant
238
+ tool_use blocks; their results (tool_result blocks in the following user
239
+ entry) become the trajectory outputs the injection scan reads, plus a short
240
+ `result` and, for successful edit/write tools, `files_changed`. The agent
241
+ intent is the latest assistant text block after the latest user turn
242
+ (thinking blocks are not used). Never raises; empty on any problem.
243
+
244
+ The pending call is left out: by its tool_use id (`current_id`, the
245
+ event's tool_use_id) when given, else the last call when its command
246
+ equals `current` and it has no output yet."""
247
+ users: List[str] = []
248
+ calls: List[TrajectoryEntry] = []
249
+ call_ids: List[str] = [] # tool_use id per call
250
+ canon: List[str] = [] # semgate canonical tool per call
251
+ targets: List[str] = [] # path argument per call ("" when none)
252
+ ids: Dict[str, int] = {}
253
+ intent = ""
254
+ empty = TranscriptContext([], (), "")
255
+ if not isinstance(path, str) or not path:
256
+ return empty
257
+ try:
258
+ with open(path, encoding="utf-8") as handle:
259
+ for line in handle:
260
+ line = line.strip()
261
+ if not line:
262
+ continue
263
+ try:
264
+ entry = json.loads(line)
265
+ except ValueError:
266
+ continue
267
+ if not isinstance(entry, Mapping) or entry.get("isMeta") or entry.get("isSidechain"):
268
+ continue
269
+ msg = entry.get("message") if isinstance(entry.get("message"), Mapping) else entry
270
+ role = str(entry.get("type") or msg.get("role") or "")
271
+ content = msg.get("content")
272
+ if role == "user":
273
+ parts = content if isinstance(content, list) else [content]
274
+ texts = []
275
+ for part in parts:
276
+ if isinstance(part, Mapping) and part.get("type") == "tool_result":
277
+ n = ids.get(str(part.get("tool_use_id", "")))
278
+ if n is not None:
279
+ full = _text_of(part.get("content"))
280
+ out = full[:_OUTPUT_CAP]
281
+ e = calls[n]
282
+ files: Tuple[str, ...] = ()
283
+ if canon[n] in _EDIT_TOOLS and not part.get("is_error"):
284
+ tur = entry.get("toolUseResult") if isinstance(entry.get("toolUseResult"), Mapping) else {}
285
+ fp = str(tur.get("filePath") or targets[n] or "")
286
+ files = (fp,) if fp else ()
287
+ calls[n] = TrajectoryEntry(tool=e.tool, decision=e.decision, summary=e.summary, output=out,
288
+ result=_result_of(canon[n], part, full), files_changed=files)
289
+ elif isinstance(part, Mapping) and part.get("type") == "text":
290
+ texts.append(str(part.get("text", "")))
291
+ elif isinstance(part, str):
292
+ texts.append(part)
293
+ text = "\n".join(t for t in texts if t).strip()
294
+ if _is_user_turn(entry, text):
295
+ users.append(text)
296
+ intent = "" # an intent stated before this turn is about an older request
297
+ elif role == "assistant" and isinstance(content, list):
298
+ for part in content:
299
+ if isinstance(part, Mapping) and part.get("type") == "text" and str(part.get("text", "")).strip():
300
+ intent = str(part.get("text", "")).strip()
301
+ elif isinstance(part, Mapping) and part.get("type") == "tool_use":
302
+ inp = part.get("input") if isinstance(part.get("input"), Mapping) else {}
303
+ summary = str(inp.get("command") or inp.get("file_path") or inp.get("path") or inp.get("url")
304
+ or inp.get("pattern") or "")[:200]
305
+ name = str(part.get("name", ""))
306
+ ids[str(part.get("id", ""))] = len(calls)
307
+ call_ids.append(str(part.get("id", "") or ""))
308
+ canon.append(_TOOLS.get(name.lower(), name.lower()))
309
+ # The path an edit/write targets; reported in files_changed only
310
+ # once its tool_result arrives without error.
311
+ targets.append(next((str(inp[k]) for k in _PATH_KEYS if isinstance(inp.get(k), str) and inp[k]), ""))
312
+ calls.append(TrajectoryEntry(tool=name, decision="", summary=summary))
313
+ except OSError:
314
+ return empty
315
+ known = frozenset(i for i in call_ids if i)
316
+ if current_id and current_id in known:
317
+ keep = [n for n, i in enumerate(call_ids) if i != current_id] # the pending call itself
318
+ calls, call_ids = [calls[n] for n in keep], [call_ids[n] for n in keep]
319
+ elif calls and current and calls[-1].summary.strip() == current.strip() and not calls[-1].output:
320
+ calls, call_ids = calls[:-1], call_ids[:-1] # the pending call itself
321
+ return TranscriptContext(users, tuple(calls[-20:]), intent[:AGENT_INTENT_MAX], tuple(call_ids[-20:]), known)
322
+
323
+
324
+ def transcript_started_at(path: Optional[str]) -> str:
325
+ """`timestamp` of the first transcript entry that has one (Claude Code
326
+ writes an ISO-8601 UTC timestamp per line). "" on any problem."""
327
+ if not isinstance(path, str) or not path:
328
+ return ""
329
+ try:
330
+ with open(path, encoding="utf-8") as handle:
331
+ for n, line in enumerate(handle):
332
+ if n > 50:
333
+ break
334
+ try:
335
+ entry = json.loads(line)
336
+ except ValueError:
337
+ continue
338
+ if isinstance(entry, Mapping) and entry.get("timestamp"):
339
+ return str(entry["timestamp"])
340
+ except OSError:
341
+ return ""
342
+ return ""
343
+
344
+
345
+ def read_transcript(path: Optional[str], current: str = "") -> Tuple[List[str], Tuple[TrajectoryEntry, ...]]:
346
+ """(user messages, trajectory) from a Claude-Code-style JSONL transcript.
347
+ See read_transcript_context. Never raises; ([], ()) on any problem."""
348
+ ctx = read_transcript_context(path, current)
349
+ return ctx.users, ctx.trace
350
+
351
+
352
+ def envelope_from_event(event: Mapping[str, Any], grant: UserGrant, *, host: str = "", evaluated_at: str = "",
353
+ tool_outputs: Optional[Sequence[Mapping[str, Any]]] = None,
354
+ merge_stats: Optional[Dict[str, Any]] = None) -> Envelope:
355
+ """`tool_outputs`: records semgate's own PostToolUse hook wrote for this
356
+ session (tooloutputs.load_for_pre), merged into the trajectory
357
+ (tooloutputs.merge_trace). `merge_stats`, when given, receives the merge
358
+ counts (evidence `tool_outputs`)."""
359
+ host = host or detect_host(event)
360
+ native, tool, args = _tool_and_args(event)
361
+ cwd = str(event.get("cwd") or "")
362
+ transcript = event.get("transcript_path") or event.get("transcriptPath")
363
+ current_id = str(event.get("tool_use_id") or "")
364
+ ctx = read_transcript_context(transcript, str(args.get("command", "")), current_id=current_id)
365
+ users, trace = ctx.users, ctx.trace
366
+ if tool_outputs:
367
+ from ..tooloutputs import merge_trace
368
+ trace, stats = merge_trace(trace, ctx.call_ids, ctx.known_ids, tool_outputs, current_id=current_id,
369
+ agent_id=str(event.get("agent_id") or ""), cap=_OUTPUT_CAP)
370
+ if merge_stats is not None:
371
+ merge_stats.update(stats)
372
+ user_message = users[-1] if users else ""
373
+ return Envelope(
374
+ schema=SCHEMA_VERSION,
375
+ action=ProposedAction(tool=tool, arguments=args),
376
+ grant=grant,
377
+ environment=Environment(project_root=cwd, cwd=cwd, harness=f"{host}-hook",
378
+ session_id=str(event.get("session_id") or event.get("sessionId") or "")),
379
+ trajectory=Trajectory(recent=trace),
380
+ evaluated_at=evaluated_at,
381
+ user_message=user_message,
382
+ user_messages=bound_user_messages(users),
383
+ agent_intent=ctx.agent_intent,
384
+ )
385
+
386
+
387
+ def user_messages(event: Mapping[str, Any]) -> List[str]:
388
+ return read_transcript(event.get("transcript_path") or event.get("transcriptPath"))[0]
389
+
390
+
391
+ def render_output(host: str, decision: str, reason: str) -> Dict[str, Any]:
392
+ """decision in allow | ask | deny (already mapped from the core's vocabulary)."""
393
+ reason = reason[:1000]
394
+ if host == "copilot":
395
+ out: Dict[str, Any] = {"permissionDecision": decision}
396
+ if decision != "allow":
397
+ out["permissionDecisionReason"] = reason
398
+ return out
399
+ if host == "devin":
400
+ # Devin's PreToolUse has no native ask: an ask must not run unattended.
401
+ return {"decision": "approve"} if decision == "allow" else {"decision": "block", "reason": reason if decision == "deny" else "semgate needs a human decision: " + reason}
402
+ return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": decision, "permissionDecisionReason": reason}}
403
+
404
+
405
+ def rendered_reason(out: Mapping[str, Any]) -> str:
406
+ """The reason text inside a render_output object ("" when none)."""
407
+ inner = out.get("hookSpecificOutput") if isinstance(out.get("hookSpecificOutput"), Mapping) else {}
408
+ return str(out.get("permissionDecisionReason") or out.get("reason") or inner.get("permissionDecisionReason") or "")
409
+
410
+
411
+ def render_post_context(host: str, text: str) -> Dict[str, Any]:
412
+ """PostToolUse output that adds `text` to the model's context (docs read
413
+ 2026-09-23): Copilot CLI takes top-level additionalContext; Claude Code,
414
+ Droid, VS Code and Devin take hookSpecificOutput.additionalContext.
415
+ Callers send it only when the host's manifest says C33 = yes."""
416
+ text = text[:4000]
417
+ if host == "copilot":
418
+ return {"additionalContext": text}
419
+ return {"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": text}}
@@ -0,0 +1,154 @@
1
+ """Codex 0.153.1 hook transcript adapter.
2
+
3
+ The rollout JSONL is an observed, version-specific format, not a stable Codex
4
+ hook API. Missing or malformed order evidence gives no chat approval.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
11
+
12
+ from ..chatapproval import Conversation, Item
13
+ from ..envelope import (AGENT_INTENT_MAX, Envelope, Environment, ProposedAction, SCHEMA_VERSION,
14
+ Trajectory, TrajectoryEntry, UserGrant, bound_user_messages, short_result)
15
+ from ..gitstate import to_epoch
16
+ from .claude_family import TranscriptContext, _OUTPUT_CAP, _tool_and_args
17
+
18
+
19
+ def _text(parts: Any, kind: str) -> str:
20
+ if not isinstance(parts, list):
21
+ return ""
22
+ return "\n".join(str(p.get("text", "")) for p in parts
23
+ if isinstance(p, Mapping) and p.get("type") == kind and isinstance(p.get("text"), str)).strip()
24
+
25
+
26
+ def _call(payload: Mapping[str, Any]) -> Tuple[str, str, Dict[str, Any]]:
27
+ name = str(payload.get("name") or "")
28
+ raw = payload.get("arguments") if payload.get("type") == "function_call" else payload.get("input")
29
+ if isinstance(raw, str) and payload.get("type") == "function_call":
30
+ try:
31
+ raw = json.loads(raw)
32
+ except ValueError:
33
+ raw = {}
34
+ if payload.get("type") == "custom_tool_call" and isinstance(raw, str):
35
+ raw = {"command": raw}
36
+ native, canonical, args = _tool_and_args({"tool_name": name, "tool_input": raw})
37
+ if name == "exec_command":
38
+ canonical = "bash"
39
+ if "command" not in args and isinstance(args.get("cmd"), str):
40
+ args["command"] = args["cmd"]
41
+ return native, canonical, args
42
+
43
+
44
+ def _read(path: Any) -> Tuple[TranscriptContext, Optional[Conversation], str]:
45
+ empty = TranscriptContext([], (), "")
46
+ if not isinstance(path, str) or not path:
47
+ return empty, None, ""
48
+ users: List[str] = []
49
+ items: List[Item] = []
50
+ calls: List[TrajectoryEntry] = []
51
+ ids: List[str] = []
52
+ canonical: List[str] = []
53
+ positions: Dict[str, int] = {}
54
+ intent = ""
55
+ started = ""
56
+ try:
57
+ with Path(path).open(encoding="utf-8") as handle:
58
+ for line in handle:
59
+ if not line.strip():
60
+ continue
61
+ row = json.loads(line)
62
+ if not isinstance(row, Mapping) or not isinstance(row.get("payload"), Mapping):
63
+ return empty, None, ""
64
+ payload = row["payload"]
65
+ ts_text = row.get("timestamp")
66
+ if not started and isinstance(ts_text, str):
67
+ started = ts_text
68
+ ts = to_epoch(ts_text)
69
+ if row.get("type") == "event_msg" and payload.get("type") == "item_completed":
70
+ item = payload.get("item")
71
+ if isinstance(item, Mapping) and item.get("type") == "UserMessage":
72
+ content = item.get("content")
73
+ text = _text(content, "text")
74
+ if text:
75
+ users.append(text)
76
+ items.append(Item("user", text, ts, msg_id=str(item.get("id") or "")))
77
+ intent = ""
78
+ elif row.get("type") == "response_item":
79
+ kind = payload.get("type")
80
+ if kind == "message" and payload.get("role") == "assistant":
81
+ text = _text(payload.get("content"), "output_text")
82
+ if text:
83
+ intent = text
84
+ items.append(Item("agent", text, ts))
85
+ elif kind in ("function_call", "custom_tool_call"):
86
+ call_id = str(payload.get("call_id") or "")
87
+ if not call_id or call_id in positions:
88
+ return empty, None, ""
89
+ native, canon, args = _call(payload)
90
+ summary = str(args.get("command") or args.get("path") or args.get("url") or "")[:200]
91
+ positions[call_id] = len(calls)
92
+ ids.append(call_id)
93
+ canonical.append(canon)
94
+ calls.append(TrajectoryEntry(tool=native, decision="", summary=summary))
95
+ items.append(Item("call", native, ts, call_id=call_id))
96
+ elif kind in ("function_call_output", "custom_tool_call_output"):
97
+ n = positions.get(str(payload.get("call_id") or ""))
98
+ if n is not None:
99
+ output = str(payload.get("output") or "")
100
+ old = calls[n]
101
+ calls[n] = TrajectoryEntry(tool=old.tool, decision="", summary=old.summary,
102
+ output=output[:_OUTPUT_CAP], result=short_result(output))
103
+ except (OSError, UnicodeError, ValueError, TypeError):
104
+ return empty, None, ""
105
+ context = TranscriptContext(users, tuple(calls[-20:]), intent[:AGENT_INTENT_MAX],
106
+ tuple(ids[-20:]), frozenset(ids))
107
+ return context, Conversation(tuple(items), complete=True, timestamps=True, source="codex rollout 0.153.1"), started
108
+
109
+
110
+ def read_transcript_context(path: Any, current_id: str = "") -> TranscriptContext:
111
+ ctx, _, _ = _read(path)
112
+ if not current_id or current_id not in ctx.known_ids:
113
+ return ctx
114
+ kept = [(entry, call_id) for entry, call_id in zip(ctx.trace, ctx.call_ids) if call_id != current_id]
115
+ return TranscriptContext(ctx.users, tuple(e for e, _ in kept), ctx.agent_intent,
116
+ tuple(i for _, i in kept), ctx.known_ids)
117
+
118
+
119
+ def chat_conversation(path: Any) -> Optional[Conversation]:
120
+ return _read(path)[1]
121
+
122
+
123
+ def transcript_started_at(path: Any) -> str:
124
+ return _read(path)[2]
125
+
126
+
127
+ def user_messages(event: Mapping[str, Any]) -> List[str]:
128
+ return _read(event.get("transcript_path"))[0].users
129
+
130
+
131
+ def envelope_from_event(event: Mapping[str, Any], grant: UserGrant, *, host: str = "codex", evaluated_at: str = "",
132
+ tool_outputs: Optional[Sequence[Mapping[str, Any]]] = None,
133
+ merge_stats: Optional[Dict[str, Any]] = None) -> Envelope:
134
+ native, tool, args = _tool_and_args(event)
135
+ if native == "exec_command":
136
+ tool = "bash"
137
+ if "command" not in args and isinstance(args.get("cmd"), str):
138
+ args["command"] = args["cmd"]
139
+ cwd = str(event.get("cwd") or "")
140
+ current_id = str(event.get("tool_use_id") or "")
141
+ ctx = read_transcript_context(event.get("transcript_path"), current_id)
142
+ trace = ctx.trace
143
+ if tool_outputs:
144
+ from ..tooloutputs import merge_trace
145
+ trace, stats = merge_trace(trace, ctx.call_ids, ctx.known_ids, tool_outputs,
146
+ current_id=current_id, agent_id=str(event.get("agent_id") or ""), cap=_OUTPUT_CAP)
147
+ if merge_stats is not None:
148
+ merge_stats.update(stats)
149
+ return Envelope(schema=SCHEMA_VERSION, action=ProposedAction(tool=tool, arguments=args), grant=grant,
150
+ environment=Environment(project_root=cwd, cwd=cwd, harness="codex-hook",
151
+ session_id=str(event.get("session_id") or "")),
152
+ trajectory=Trajectory(recent=trace), evaluated_at=evaluated_at,
153
+ user_message=ctx.users[-1] if ctx.users else "",
154
+ user_messages=bound_user_messages(ctx.users), agent_intent=ctx.agent_intent)
@@ -0,0 +1,73 @@
1
+ """OpenCode adapter, based on OpenCode's current permission flow.
2
+
3
+ OpenCode resolves each action against its `permission` config: static "allow"
4
+ runs, static "deny" is blocked, and everything else lands in the residual
5
+ "ask" bucket and prompts the user. Plugins observe that bucket through the
6
+ `permission.asked` event.
7
+
8
+ Shadow-mode wiring: a tiny plugin (examples/opencode-plugin/semgate-shadow.js)
9
+ subscribes to `permission.asked`, ships the event here, and logs our decision.
10
+ It never calls `permission.replied` and never influences the host. We judge.
11
+ The host acts.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, Dict, Mapping, Optional
16
+
17
+ from ..envelope import Envelope, Environment, ProposedAction, Trajectory, TrajectoryEntry, UserGrant
18
+
19
+ ADAPTER_NAME = "opencode"
20
+
21
+
22
+ def envelope_from_permission_event(
23
+ event: Mapping[str, Any],
24
+ grant: UserGrant,
25
+ directory: str = "",
26
+ session_id: str = "",
27
+ ) -> Envelope:
28
+ """Translate an OpenCode `permission.asked` event into an envelope.
29
+
30
+ Tolerant extraction: the event shape has moved between versions, so we
31
+ read the fields that exist and leave the rest empty (missing evidence
32
+ then routes to ASK rather than to a guess).
33
+ """
34
+ props: Mapping[str, Any] = event.get("properties") or event
35
+ permission = str(props.get("permission") or props.get("tool") or "")
36
+ patterns = props.get("patterns") or []
37
+ metadata: Mapping[str, Any] = props.get("metadata") or {}
38
+
39
+ arguments: Dict[str, Any] = {}
40
+ if permission == "bash":
41
+ command = metadata.get("command") or (patterns[0] if patterns else "")
42
+ arguments["command"] = command
43
+ elif permission in ("read", "edit", "glob", "grep"):
44
+ path = metadata.get("path") or metadata.get("filePath") or (patterns[0] if patterns else "")
45
+ arguments["path"] = path
46
+ elif permission == "webfetch":
47
+ arguments["url"] = metadata.get("url") or (patterns[0] if patterns else "")
48
+ else:
49
+ if patterns:
50
+ arguments["patterns"] = list(patterns)
51
+ for key, value in metadata.items():
52
+ arguments.setdefault(key, value)
53
+
54
+ environment = Environment(
55
+ project_root=directory,
56
+ cwd=directory,
57
+ harness=ADAPTER_NAME,
58
+ session_id=session_id or str(props.get("sessionID") or ""),
59
+ )
60
+ return Envelope(
61
+ schema="semgate-envelope/1",
62
+ action=ProposedAction(tool=permission or "unknown", arguments=arguments),
63
+ grant=grant,
64
+ environment=environment,
65
+ trajectory=Trajectory(recent=()),
66
+ )
67
+
68
+
69
+ def grant_from_config(d: Mapping[str, Any]) -> UserGrant:
70
+ """Build the immutable grant from an adapter config dict. The grant is
71
+ supplied by the operator wiring the plugin, never inferred from the
72
+ agent's own request."""
73
+ return UserGrant.from_dict(d)