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.
- semgate/__init__.py +10 -0
- semgate/__main__.py +3 -0
- semgate/action_identity.py +64 -0
- semgate/adapters/__init__.py +0 -0
- semgate/adapters/antigravity.py +419 -0
- semgate/adapters/claude_family.py +419 -0
- semgate/adapters/codex.py +154 -0
- semgate/adapters/opencode.py +73 -0
- semgate/adapters/opencode_tool.py +435 -0
- semgate/adapters/pi.py +121 -0
- semgate/adminguard.py +314 -0
- semgate/agentfiles.py +298 -0
- semgate/antigravity_hook.py +738 -0
- semgate/antigravity_post_hook.py +42 -0
- semgate/approvals.py +221 -0
- semgate/assets/__init__.py +1 -0
- semgate/assets/opencode_semgate.js +309 -0
- semgate/assets/pi_semgate.ts +125 -0
- semgate/assets/semgate_skill.md +77 -0
- semgate/calibration.py +259 -0
- semgate/capabilities.py +36 -0
- semgate/chatapproval.py +842 -0
- semgate/claude_hook.py +221 -0
- semgate/cli.py +645 -0
- semgate/client.py +221 -0
- semgate/codesignals.py +1702 -0
- semgate/codestamp.py +223 -0
- semgate/data/approve_request.schema.json +16 -0
- semgate/data/check_request.schema.json +43 -0
- semgate/data/check_response.schema.json +19 -0
- semgate/data/demo_recorded.json +306 -0
- semgate/data/hosts/antigravity.json +36 -0
- semgate/data/hosts/claude.json +43 -0
- semgate/data/hosts/codex.json +43 -0
- semgate/data/hosts/droid.json +36 -0
- semgate/data/hosts/opencode-v1.json +42 -0
- semgate/data/hosts/opencode-v2.json +42 -0
- semgate/data/hosts/pi.json +42 -0
- semgate/data/model_limits.json +29 -0
- semgate/demo.py +355 -0
- semgate/doctor.py +207 -0
- semgate/enforcement.py +137 -0
- semgate/envelope.py +347 -0
- semgate/eval/__init__.py +5 -0
- semgate/eval/case.py +79 -0
- semgate/eval/chat_approval.py +472 -0
- semgate/eval/exposure_intent.py +157 -0
- semgate/eval/importers.py +82 -0
- semgate/eval/metrics.py +61 -0
- semgate/eval/runner.py +144 -0
- semgate/eval/trust_pin.py +303 -0
- semgate/evidence.py +30 -0
- semgate/exposures.py +628 -0
- semgate/extractor.py +286 -0
- semgate/feedback.py +272 -0
- semgate/filelock.py +366 -0
- semgate/fingerprints.py +151 -0
- semgate/gate.py +218 -0
- semgate/gemini_gate.py +314 -0
- semgate/gitstate.py +384 -0
- semgate/harness.py +615 -0
- semgate/harnesstools.py +216 -0
- semgate/history.py +133 -0
- semgate/hookinput.py +267 -0
- semgate/hosts/__init__.py +57 -0
- semgate/hosts/base.py +338 -0
- semgate/hosts/builtin.py +1191 -0
- semgate/hosts/installed.py +62 -0
- semgate/hosts/pitrust.py +142 -0
- semgate/httpserve.py +573 -0
- semgate/init_antigravity.py +572 -0
- semgate/injection.py +728 -0
- semgate/judge.py +758 -0
- semgate/ledger.py +258 -0
- semgate/linkplace.py +1346 -0
- semgate/ownmessages.py +434 -0
- semgate/payloadsize.py +112 -0
- semgate/pingate.py +463 -0
- semgate/pins.py +566 -0
- semgate/policies/__init__.py +3 -0
- semgate/policies/default_policy.json +87 -0
- semgate/policies/known-benign-capabilities.json +5 -0
- semgate/policies/profiles.json +69 -0
- semgate/policies/purpose_grant_policy.json +58 -0
- semgate/policies/router_policy.json +48 -0
- semgate/policies/router_policy_dev.json +187 -0
- semgate/policies/router_policy_dev_all.json +126 -0
- semgate/policies/router_policy_dev_all_no_crit.json +114 -0
- semgate/policies/router_policy_dev_all_no_lastcheck.json +125 -0
- semgate/policies/router_policy_dev_all_no_s3.json +125 -0
- semgate/policies/router_policy_dev_all_no_s4.json +125 -0
- semgate/policies/router_policy_dev_all_no_sdrift.json +124 -0
- semgate/policies/router_policy_dev_buildfacts.json +176 -0
- semgate/policies/router_policy_dev_chatapprove.json +152 -0
- semgate/policies/router_policy_dev_chatdecline.json +185 -0
- semgate/policies/router_policy_dev_crit.json +113 -0
- semgate/policies/router_policy_dev_exposure.json +136 -0
- semgate/policies/router_policy_dev_final.json +125 -0
- semgate/policies/router_policy_dev_g1.json +99 -0
- semgate/policies/router_policy_dev_g12.json +100 -0
- semgate/policies/router_policy_dev_g12b.json +101 -0
- semgate/policies/router_policy_dev_g2.json +99 -0
- semgate/policies/router_policy_dev_pinq.json +175 -0
- semgate/policies/router_policy_dev_s3.json +105 -0
- semgate/policies/router_policy_dev_s3f.json +106 -0
- semgate/policies/router_policy_dev_s4.json +105 -0
- semgate/policies/router_policy_dev_s4allow.json +177 -0
- semgate/policies/router_policy_dev_s5.json +124 -0
- semgate/policies/router_policy_dev_s6.json +151 -0
- semgate/policies/router_policy_dev_sdrift.json +103 -0
- semgate/policies/router_policy_dev_testrun.json +175 -0
- semgate/policies/router_policy_dev_trust.json +169 -0
- semgate/policies/router_policy_dev_turns.json +106 -0
- semgate/policies/router_policy_f7.json +105 -0
- semgate/policies/router_policy_trace.json +66 -0
- semgate/policies/router_policy_v2.json +51 -0
- semgate/policies/router_policy_v3.json +67 -0
- semgate/policies/router_policy_v3c.json +69 -0
- semgate/policies/router_policy_v3c_lite.json +69 -0
- semgate/policy.py +96 -0
- semgate/predicates.py +43 -0
- semgate/proc.py +23 -0
- semgate/profiles.py +298 -0
- semgate/providers/__init__.py +4 -0
- semgate/providers/base.py +44 -0
- semgate/providers/common.py +129 -0
- semgate/providers/fake.py +61 -0
- semgate/providers/keys.py +147 -0
- semgate/providers/openrouter.py +365 -0
- semgate/providers/recorded.py +153 -0
- semgate/providers/registry.py +57 -0
- semgate/providers/typesafe.py +171 -0
- semgate/replay.py +128 -0
- semgate/replay_offline.py +306 -0
- semgate/report.py +353 -0
- semgate/router.py +776 -0
- semgate/rules.py +1123 -0
- semgate/safemerge.py +606 -0
- semgate/scriptsource.py +494 -0
- semgate/secretfinder.py +415 -0
- semgate/serve.py +641 -0
- semgate/shellparse.py +535 -0
- semgate/skill.py +278 -0
- semgate/storepaths.py +194 -0
- semgate/telemetry.py +309 -0
- semgate/testrun.py +2416 -0
- semgate/tooloutputs.py +490 -0
- semgate/trust.py +914 -0
- semgate/trustauth.py +756 -0
- semgate/trustgate.py +496 -0
- semgate-0.4.0.dist-info/METADATA +1581 -0
- semgate-0.4.0.dist-info/RECORD +157 -0
- semgate-0.4.0.dist-info/WHEEL +5 -0
- semgate-0.4.0.dist-info/entry_points.txt +4 -0
- semgate-0.4.0.dist-info/licenses/LICENSE +202 -0
- semgate-0.4.0.dist-info/licenses/NOTICE +8 -0
- 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)
|