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
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,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
|
+
)
|