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
semgate/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """semgate - harness-agnostic semantic auto mode (enforce mode: the host acts on semgate's decision).
2
+
3
+ We judge. The host acts.
4
+ """
5
+
6
+ __version__ = "0.4.0" # the one version: pyproject.toml reads it (dynamic = ["version"])
7
+
8
+ from .gate import Gate # noqa: E402,F401 - the embed-in-your-own-agent entry point
9
+ from .judge import Decision # noqa: E402,F401
10
+ from .harness import approve, check # noqa: E402,F401 - the hook pipeline for any harness (dict in, dict out)
semgate/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,64 @@
1
+ """Lossless request identity, not an approval or an authorization mechanism.
2
+
3
+ V1 feedback/history keys are intentionally not accepted or migrated here.
4
+ A digest identifies data; it does not authenticate its sender or freeze files.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import math
11
+ from typing import Any, Mapping
12
+
13
+ SCHEMA = "semgate-action/2"
14
+ CONTEXT_FIELDS = (
15
+ "harness", "harness_version", "session_id", "cwd", "project_root", "shell"
16
+ )
17
+
18
+
19
+ def _json_value(value: Any, depth: int = 0) -> Any:
20
+ if depth > 32:
21
+ raise ValueError("JSON nesting exceeds 32 levels")
22
+ if value is None or type(value) in (bool, int):
23
+ return value
24
+ if type(value) is str:
25
+ value.encode("utf-8") # Reject unpaired surrogates, never normalize text.
26
+ return value
27
+ if type(value) is float and math.isfinite(value):
28
+ return value
29
+ if isinstance(value, Mapping):
30
+ if any(type(key) is not str for key in value):
31
+ raise ValueError("JSON object keys must be strings")
32
+ return {key: _json_value(item, depth + 1) for key, item in value.items()}
33
+ if type(value) is list:
34
+ return [_json_value(item, depth + 1) for item in value]
35
+ raise ValueError("only finite JSON values are supported")
36
+
37
+
38
+ def canonical_json(value: Any) -> str:
39
+ """Sort object keys only. Preserve case, whitespace, types and list order."""
40
+ return json.dumps(_json_value(value), sort_keys=True, separators=(",", ":"),
41
+ ensure_ascii=True, allow_nan=False)
42
+
43
+
44
+ def request_identity(*, tool: str, arguments: Mapping[str, Any],
45
+ context: Mapping[str, Any], grant: Mapping[str, Any],
46
+ policy_version: str, provider: str) -> str:
47
+ """Bind complete arguments, grant, policy/provider and execution context.
48
+
49
+ All strings are compared exactly. False misses are preferable to merging
50
+ distinct commands. This is an audit/correlation key, NOT a reusable permit.
51
+ A future approval receipt additionally needs a trusted issuer, pending-call
52
+ binding, one-shot consumption, expiry and execution-time revalidation.
53
+ """
54
+ required = {"tool": tool, "policy_version": policy_version, "provider": provider}
55
+ required.update({key: context.get(key) for key in CONTEXT_FIELDS})
56
+ if any(type(value) is not str or not value.strip() for value in required.values()):
57
+ raise ValueError("identity requires explicit tool, policy, provider and context")
58
+ if not isinstance(arguments, Mapping) or not isinstance(grant, Mapping) or not grant:
59
+ raise ValueError("arguments and a nonempty grant must be JSON objects")
60
+ payload = {"schema": SCHEMA, "tool": tool, "arguments": arguments,
61
+ "context": context, "grant": grant,
62
+ "policy_version": policy_version, "provider": provider}
63
+ digest = hashlib.sha256(canonical_json(payload).encode("utf-8")).hexdigest()
64
+ return SCHEMA + ":" + digest
File without changes
@@ -0,0 +1,419 @@
1
+ """Google Antigravity PreToolUse adapter.
2
+
3
+ Native hook input is untrusted event data. The adapter copies only documented
4
+ fields into semgate's canonical envelope; it never reads a grant from the
5
+ agent's tool arguments or transcript.
6
+ """
7
+ from __future__ import annotations
8
+ import json
9
+ import re
10
+ import urllib.parse
11
+ from typing import Any, List, Mapping, NamedTuple, Optional, Tuple
12
+ from ..envelope import (AGENT_INTENT_MAX, Envelope, Environment, ProposedAction, SCHEMA_VERSION, Trajectory, TrajectoryEntry,
13
+ UserGrant, bound_user_messages, short_result)
14
+
15
+ _USER_REQUEST_RE = re.compile(r"<USER_REQUEST>\s*(.*?)\s*</USER_REQUEST>", re.S)
16
+ # Transcript step types that carry a tool's result in `content` (observed in
17
+ # agy 1.2.x transcripts). PLANNER_RESPONSE / USER_INPUT / SYSTEM_MESSAGE are not
18
+ # tool output and are never attached.
19
+ _OUTPUT_STEP_TYPES = frozenset({
20
+ "VIEW_FILE", "RUN_COMMAND", "COMMAND_STATUS", "READ_URL_CONTENT", "SEARCH_WEB",
21
+ "GREP_SEARCH", "LIST_DIRECTORY", "FIND_BY_NAME", "CODE_ACTION", "GENERIC",
22
+ })
23
+ _OUTPUT_CAP = 6000 # chars kept per tool output
24
+
25
+
26
+ def user_requests(path: Optional[str]) -> List[str]:
27
+ """Every explicit user message of the session, oldest first, from agy's
28
+ transcript. Only USER_INPUT steps whose source is the user count; a step the
29
+ model or system wrote is never returned. [] on any problem."""
30
+ out: List[str] = []
31
+ if not isinstance(path, str) or not path:
32
+ return out
33
+ try:
34
+ with open(path, encoding="utf-8") as handle:
35
+ for line in handle:
36
+ line = line.strip()
37
+ if not line:
38
+ continue
39
+ try:
40
+ entry = json.loads(line)
41
+ except ValueError:
42
+ continue
43
+ if not isinstance(entry, Mapping) or entry.get("type") != "USER_INPUT":
44
+ continue
45
+ if str(entry.get("source", "USER_EXPLICIT")).upper() != "USER_EXPLICIT":
46
+ continue
47
+ content = str(entry.get("content", ""))
48
+ match = _USER_REQUEST_RE.search(content)
49
+ text = (match.group(1) if match else content).strip()
50
+ if text:
51
+ out.append(text)
52
+ except OSError:
53
+ return []
54
+ return out
55
+
56
+
57
+ def transcript_started_at(path: Optional[str]) -> str:
58
+ """`created_at` of the first transcript step that has one (agy 1.2.8
59
+ writes ISO-8601 UTC, e.g. "2026-09-21T01:03:37Z"; seen in a local
60
+ transcript_full.jsonl 2026-09-23). "" on any problem."""
61
+ if not isinstance(path, str) or not path:
62
+ return ""
63
+ try:
64
+ with open(path, encoding="utf-8") as handle:
65
+ for n, line in enumerate(handle):
66
+ if n > 50:
67
+ break
68
+ try:
69
+ entry = json.loads(line)
70
+ except ValueError:
71
+ continue
72
+ if isinstance(entry, Mapping) and entry.get("created_at"):
73
+ return str(entry["created_at"])
74
+ except OSError:
75
+ return ""
76
+ return ""
77
+
78
+
79
+ def chat_conversation(path: Optional[str]) -> Any:
80
+ """The transcript as ordered items for approval by chat reply
81
+ (chatapproval.Conversation), or None when there is no readable
82
+ transcript. User items: USER_INPUT steps whose source is USER_EXPLICIT
83
+ (the same rule as user_requests), stamped with `created_at` (1 s
84
+ resolution). Agent items: PLANNER_RESPONSE `content`. Call items: each
85
+ tool call (agy's PreToolUse stepIdx is not matched to a step: not
86
+ verified). Tool-result steps and SYSTEM steps are not items."""
87
+ from ..chatapproval import Conversation, Item
88
+ from ..gitstate import to_epoch
89
+ if not isinstance(path, str) or not path:
90
+ return None
91
+ items: List[Item] = []
92
+ try:
93
+ with open(path, encoding="utf-8") as handle:
94
+ for line in handle:
95
+ line = line.strip()
96
+ if not line:
97
+ continue
98
+ try:
99
+ entry = json.loads(line)
100
+ except ValueError:
101
+ continue
102
+ if not isinstance(entry, Mapping):
103
+ continue
104
+ etype = entry.get("type")
105
+ ts = to_epoch(entry.get("created_at"))
106
+ if etype == "USER_INPUT":
107
+ if str(entry.get("source", "USER_EXPLICIT")).upper() != "USER_EXPLICIT":
108
+ continue
109
+ content = str(entry.get("content", ""))
110
+ match = _USER_REQUEST_RE.search(content)
111
+ text = (match.group(1) if match else content).strip()
112
+ if text:
113
+ items.append(Item("user", text, ts, msg_id=f"step:{entry.get('step_index', '')}"))
114
+ elif etype == "PLANNER_RESPONSE":
115
+ text = str(entry.get("content") or "").strip()
116
+ if text:
117
+ items.append(Item("agent", text, ts))
118
+ for call in entry.get("tool_calls") or []:
119
+ if isinstance(call, Mapping):
120
+ items.append(Item("call", str(call.get("name", "")), ts))
121
+ except OSError:
122
+ return None
123
+ return Conversation(tuple(items), complete=True, timestamps=True, source="agy transcript")
124
+
125
+
126
+ def first_user_request(path: Optional[str]) -> str:
127
+ """The user's first explicit message of the session ("" if none)."""
128
+ msgs = user_requests(path)
129
+ return msgs[0] if msgs else ""
130
+
131
+
132
+ _EDIT_TOOLS = frozenset({"write_to_file", "replace_file_content", "multi_replace_file_content"})
133
+ _STAMP_RE = re.compile(r"^(Created|Completed) At:")
134
+ _EXIT_FAIL_RE = re.compile(r"^The command failed with exit code:\s*(-?\d+)")
135
+ _CREATED_RE = re.compile(r"^Created file (\S+)")
136
+ _CHANGED_RE = re.compile(r"^The following changes were made by the (\S+)(?: tool)? to:\s*(.+)$")
137
+
138
+
139
+ def _unquote(value: Any) -> str:
140
+ """agy tool args are often JSON-encoded strings ('"c:\\\\x\\\\a.py"')."""
141
+ text = str(value or "").strip()
142
+ if text.startswith('"'):
143
+ try:
144
+ decoded = json.loads(text)
145
+ return decoded if isinstance(decoded, str) else text
146
+ except ValueError:
147
+ return text.strip('"')
148
+ return text
149
+
150
+
151
+ def _step_result(step_type: str, content: str) -> Tuple[str, Tuple[str, ...]]:
152
+ """(result, files_changed) of an agy tool-result step. Shapes observed in
153
+ agy 1.2.x transcripts (2026-09-23): RUN_COMMAND "The command completed
154
+ successfully." / "The command failed with exit code: N" then "Output:";
155
+ CODE_ACTION "Created file file:///..." / "The following changes were made
156
+ by the <tool> tool to: <path>" ("by the USER" is the user's own edit and is
157
+ not reported as changed by the agent)."""
158
+ lines = [ln.strip() for ln in str(content or "").splitlines()]
159
+ lines = [ln for ln in lines if ln and not _STAMP_RE.match(ln)]
160
+ if not lines:
161
+ return "", ()
162
+ head, rest = lines[0], [ln for ln in lines[1:] if ln != "Output:"]
163
+ if step_type == "RUN_COMMAND":
164
+ if head.startswith("The command completed successfully"):
165
+ return short_result("\n".join(rest), exit_code=0), ()
166
+ m = _EXIT_FAIL_RE.match(head)
167
+ if m:
168
+ return short_result("\n".join(rest), exit_code=m.group(1)), ()
169
+ if head.startswith("Encountered error"):
170
+ return short_result("\n".join(lines), error=True), ()
171
+ files: Tuple[str, ...] = ()
172
+ if step_type == "CODE_ACTION":
173
+ created, changed = _CREATED_RE.match(head), _CHANGED_RE.match(head)
174
+ path = created.group(1) if created else (changed.group(2).strip() if changed and changed.group(1) != "USER" else "")
175
+ if path.startswith("file://"):
176
+ path = urllib.parse.unquote(path[len("file://"):])
177
+ if re.match(r"^/[A-Za-z]:", path):
178
+ path = path[1:] # file:///C:/x -> C:/x
179
+ files = (path,) if path else ()
180
+ return short_result("\n".join(lines)), files
181
+
182
+
183
+ class TranscriptContext(NamedTuple):
184
+ user_message: str # latest USER_INPUT (any source; as before)
185
+ trace: Tuple[TrajectoryEntry, ...] # recent calls with output / result / files_changed
186
+ user_messages: Tuple[str, ...] # every USER_EXPLICIT turn, oldest first (bounded)
187
+ agent_intent: str # latest PLANNER_RESPONSE text after the latest user turn
188
+
189
+
190
+ def _read_transcript(path: str, current_command: str) -> Tuple[str, Tuple[TrajectoryEntry, ...]]:
191
+ """Best-effort read of agy's transcript.jsonl: the latest user request (the
192
+ goal) and the recent tool calls (the trajectory), excluding the pending call.
193
+ Untrusted data, never a grant. Never raises; returns ("", ()) on any problem."""
194
+ ctx = read_transcript_context(path, current_command)
195
+ return ctx.user_message, ctx.trace
196
+
197
+
198
+ _FILE_PATH_RE = re.compile(r"^File Path:\s*`?(file://[^`\s]+|[^`\n]+?)`?\s*$", re.M)
199
+
200
+
201
+ def _path_key(value: Any) -> str:
202
+ """A file path or file:// URL in one comparable form: unquoted, no
203
+ file:// prefix, forward slashes, no leading slash before a drive letter,
204
+ lowercase (agy runs on Windows too). "" when empty."""
205
+ text = _unquote(value).strip()
206
+ if text.lower().startswith("file://"):
207
+ text = urllib.parse.unquote(text[len("file://"):])
208
+ text = text.replace("\\", "/")
209
+ if re.match(r"^/[A-Za-z]:", text):
210
+ text = text[1:]
211
+ return text.rstrip("/").lower()
212
+
213
+
214
+ def _is_edit_notice(content: str) -> bool:
215
+ """True when the step's first line (after the Created/Completed At
216
+ stamps) is agy's edit notice: "Created file <path>" or "The following
217
+ changes were made by the <tool or USER> to: <path>"."""
218
+ for line in str(content or "").splitlines():
219
+ line = line.strip()
220
+ if not line or _STAMP_RE.match(line):
221
+ continue
222
+ return bool(_CREATED_RE.match(line) or _CHANGED_RE.match(line))
223
+ return False
224
+
225
+
226
+ def _result_path(content: str, files: Tuple[str, ...]) -> str:
227
+ """The file a tool-result step names about itself: view_file's "File
228
+ Path: `file:///...`" line, or the file an edit result reports. "" when
229
+ the step names none."""
230
+ m = _FILE_PATH_RE.search(content[:2000])
231
+ if m:
232
+ return _path_key(m.group(1))
233
+ return _path_key(files[0]) if files else ""
234
+
235
+
236
+ def _owner(batch: List[int], answered: set, keys: List[str], content: str, files: Tuple[str, ...]) -> Optional[int]:
237
+ """Index of the call a tool-result step belongs to. agy 1.2.x result steps
238
+ carry no call id (checked on 73 local transcripts 2026-09-24: steps have
239
+ only type/source/status/created_at/step_index/content). The step is paired
240
+ within the calls of the latest PLANNER_RESPONSE that has tool calls
241
+ (`batch`), never with an older call: first by the file the result names
242
+ (a view_file or edit result), else the first call of the batch that has
243
+ no result yet (results follow their calls in call order: both multi-call
244
+ responses in those transcripts). None when every call of the batch has
245
+ its result."""
246
+ open_calls = [i for i in batch if i not in answered]
247
+ if not open_calls:
248
+ return None
249
+ named = _result_path(content, files)
250
+ if named:
251
+ for i in open_calls:
252
+ if keys[i] and keys[i] == named:
253
+ return i
254
+ return open_calls[0]
255
+
256
+
257
+ def read_transcript_context(path: str, current_command: str) -> TranscriptContext:
258
+ """See _read_transcript; also every explicit user turn (the same rule as
259
+ user_requests), a short `result` and `files_changed` per call, and the
260
+ agent's latest stated intent (PLANNER_RESPONSE `content`, not `thinking`).
261
+
262
+ Tool results are paired with calls by _owner. Before 2026-09-24 each
263
+ result went to the most recent call without output, so two parallel
264
+ view_file calls (AGENTS.md, SKILL.md) got each other's text: the entry
265
+ labeled AGENTS.md held SKILL.md, and pins / evidence labels read the
266
+ wrong file."""
267
+ user_message = ""
268
+ recent: List[TrajectoryEntry] = []
269
+ edit_target: List[str] = [] # TargetFile arg per call ("" when none)
270
+ path_keys: List[str] = [] # _path_key of the file a call reads or edits ("" when none)
271
+ batch: List[int] = [] # indexes of the latest PLANNER_RESPONSE's calls
272
+ answered: set = set() # indexes of calls that have their result
273
+ users: List[str] = []
274
+ intent = ""
275
+ try:
276
+ with open(path, encoding="utf-8") as handle:
277
+ for line in handle:
278
+ line = line.strip()
279
+ if not line:
280
+ continue
281
+ try:
282
+ entry = json.loads(line)
283
+ except ValueError:
284
+ continue
285
+ if not isinstance(entry, Mapping):
286
+ continue
287
+ etype = entry.get("type")
288
+ if etype == "USER_INPUT":
289
+ content = str(entry.get("content", ""))
290
+ match = _USER_REQUEST_RE.search(content)
291
+ user_message = (match.group(1) if match else content).strip()
292
+ if str(entry.get("source", "USER_EXPLICIT")).upper() == "USER_EXPLICIT" and user_message:
293
+ users.append(user_message)
294
+ intent = "" # an intent stated before this turn is about an older request
295
+ elif etype == "PLANNER_RESPONSE":
296
+ text = str(entry.get("content") or "").strip()
297
+ if text:
298
+ intent = text
299
+ calls = [c for c in (entry.get("tool_calls") or []) if isinstance(c, Mapping)]
300
+ if calls:
301
+ batch = []
302
+ for call in calls:
303
+ cargs = call.get("args") if isinstance(call.get("args"), Mapping) else {}
304
+ summary = str(cargs.get("CommandLine") or cargs.get("command")
305
+ or cargs.get("FilePath") or cargs.get("AbsolutePath") # agy 1.2.8 view_file uses AbsolutePath
306
+ or cargs.get("Url") or "")[:200]
307
+ batch.append(len(recent))
308
+ recent.append(TrajectoryEntry(tool=str(call.get("name", "")), decision="", summary=summary))
309
+ target = _unquote(cargs.get("TargetFile")) if str(call.get("name", "")) in _EDIT_TOOLS else ""
310
+ edit_target.append(target)
311
+ path_keys.append(_path_key(target or cargs.get("AbsolutePath") or cargs.get("FilePath") or ""))
312
+ elif entry.get("source") == "MODEL" and etype in _OUTPUT_STEP_TYPES and recent:
313
+ # A tool-result step (VIEW_FILE, RUN_COMMAND, READ_URL_CONTENT,
314
+ # ...) follows its call. Its content is attached to the call
315
+ # _owner picks: this is the untrusted text the injection scan
316
+ # reads. Truncated; never parsed as a grant.
317
+ content = str(entry.get("content", ""))
318
+ result, files = _step_result(str(etype), content)
319
+ i = _owner(batch, answered, path_keys, content, files)
320
+ if i is None:
321
+ # No open call in the latest batch (not seen in the 73 local
322
+ # agy 1.2.x transcripts). An edit notice ("Created file ...",
323
+ # "The following changes were made by the USER to: ...")
324
+ # names only a path and is skipped, as before. Any other text
325
+ # is kept for the injection scan as its own entry rather than
326
+ # given to an unrelated call.
327
+ if _is_edit_notice(content):
328
+ continue
329
+ recent.append(TrajectoryEntry(tool=str(etype).lower(), decision="", summary="",
330
+ output=content[:_OUTPUT_CAP], result=result, files_changed=files))
331
+ edit_target.append("")
332
+ path_keys.append("")
333
+ answered.add(len(recent) - 1)
334
+ continue
335
+ answered.add(i)
336
+ if files and edit_target[i]:
337
+ files = (edit_target[i],) # the call's own TargetFile, when it has one
338
+ recent[i] = TrajectoryEntry(tool=recent[i].tool, decision=recent[i].decision,
339
+ summary=recent[i].summary, output=content[:_OUTPUT_CAP],
340
+ result=result, files_changed=files)
341
+ except OSError:
342
+ return TranscriptContext("", (), (), "")
343
+ if recent and current_command and recent[-1].summary.strip().strip('"') == current_command.strip().strip('"'):
344
+ recent = recent[:-1] # the pending call is already in the transcript; don't echo it as history
345
+ return TranscriptContext(user_message, tuple(recent[-20:]), bound_user_messages(users), intent[:AGENT_INTENT_MAX])
346
+
347
+ _TOOL_ALIASES = {"view_file": "read", "read_file": "read", "list_directory": "ls", "grep_search": "grep", "run_command": "bash"}
348
+
349
+ _ARG_ALIASES = {
350
+ "CommandLine": "command", "commandLine": "command", "command": "command",
351
+ "Cwd": "cwd", "cwd": "cwd",
352
+ "FilePath": "path", "filePath": "path", "path": "path",
353
+ "AbsolutePath": "path", "absolutePath": "path",
354
+ "DirectoryPath": "directory", "directoryPath": "directory", "directory": "directory",
355
+ "Url": "url", "URL": "url", "url": "url",
356
+ }
357
+
358
+ def grant_from_config(raw: Mapping[str, Any]) -> UserGrant:
359
+ return UserGrant.from_dict(raw)
360
+
361
+ def envelope_from_pre_tool_use(event: Mapping[str, Any], grant: UserGrant, *, evaluated_at: str = "") -> Envelope:
362
+ call = event.get("toolCall") if isinstance(event.get("toolCall"), Mapping) else {}
363
+ native_tool = str(call.get("name", ""))
364
+ tool = _TOOL_ALIASES.get(native_tool, native_tool)
365
+ native_args = call.get("args") if isinstance(call.get("args"), Mapping) else {}
366
+ args = dict(native_args)
367
+ for native, canonical in _ARG_ALIASES.items():
368
+ value = native_args.get(native)
369
+ if value not in (None, "") and canonical not in args:
370
+ args[canonical] = value
371
+ workspace = event.get("workspacePaths")
372
+ workspace_paths = [str(p) for p in workspace] if isinstance(workspace, list) else []
373
+ root = workspace_paths[0] if workspace_paths else ""
374
+ # Trajectory + user goal. Preferred source is agy's transcriptPath; fall back
375
+ # to an inline recentToolCalls list if a host provides one. The user's latest
376
+ # message comes only from the host's own transcript, never from tool args.
377
+ recent: list = []
378
+ raw_recent = event.get("recentToolCalls")
379
+ if isinstance(raw_recent, list):
380
+ for item in raw_recent[-20:]:
381
+ if isinstance(item, Mapping):
382
+ recent.append(TrajectoryEntry(
383
+ tool=str(item.get("name", item.get("tool", ""))),
384
+ decision=str(item.get("decision", "")),
385
+ summary=str(item.get("summary", ""))[:500],
386
+ ))
387
+ user_message = str(event.get("userMessage", "") or "")
388
+ transcript_path = event.get("transcriptPath")
389
+ user_turns: Tuple[str, ...] = ()
390
+ agent_intent = ""
391
+ if isinstance(transcript_path, str) and transcript_path:
392
+ ctx = read_transcript_context(transcript_path, str(args.get("command", "")))
393
+ goal, trace = ctx.user_message, ctx.trace
394
+ if goal and not user_message:
395
+ user_message = goal
396
+ if trace and not recent:
397
+ recent = list(trace)
398
+ user_turns, agent_intent = ctx.user_messages, ctx.agent_intent
399
+ if user_turns and user_message and user_turns[-1] != user_message:
400
+ # The event's own userMessage (or a non-explicit latest input) is the
401
+ # latest turn; keep user_messages[-1] == user_message.
402
+ user_turns = bound_user_messages(list(user_turns) + [user_message])
403
+ return Envelope(
404
+ schema=SCHEMA_VERSION,
405
+ action=ProposedAction(tool=tool, arguments=args),
406
+ grant=grant,
407
+ environment=Environment(
408
+ project_root=root,
409
+ cwd=str(args.get("cwd", root)),
410
+ harness="google-antigravity",
411
+ harness_version=str(event.get("harnessVersion", "")),
412
+ session_id=str(event.get("conversationId", "")),
413
+ ),
414
+ trajectory=Trajectory(recent=tuple(recent)),
415
+ evaluated_at=evaluated_at,
416
+ user_message=user_message,
417
+ user_messages=user_turns,
418
+ agent_intent=agent_intent,
419
+ )