agentseam 0.1.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.
- agentseam/__init__.py +83 -0
- agentseam/adapters/__init__.py +69 -0
- agentseam/adapters/_windows.py +32 -0
- agentseam/adapters/antigravity.py +189 -0
- agentseam/adapters/claude_code.py +299 -0
- agentseam/adapters/codex_cli.py +261 -0
- agentseam/adapters/cursor.py +300 -0
- agentseam/adapters/devin.py +213 -0
- agentseam/adapters/gemini_cli.py +202 -0
- agentseam/adapters/grok.py +175 -0
- agentseam/adapters/junie.py +182 -0
- agentseam/adapters/kimi_code.py +225 -0
- agentseam/adapters/tabnine.py +169 -0
- agentseam/adapters/vscode_copilot.py +300 -0
- agentseam/adapters/windsurf.py +163 -0
- agentseam/allow_semantics.py +146 -0
- agentseam/bundler.py +197 -0
- agentseam/bundler_templates.py +120 -0
- agentseam/cli.py +283 -0
- agentseam/contract.py +216 -0
- agentseam/dispatch.py +150 -0
- agentseam/install.py +81 -0
- agentseam/install_config.py +214 -0
- agentseam/install_identity.py +133 -0
- agentseam/instructions.py +203 -0
- agentseam/matrix.py +118 -0
- agentseam/matrix_data.py +258 -0
- agentseam/matrix_evidence.py +227 -0
- agentseam/matrix_gaps.py +64 -0
- agentseam/matrix_notes.py +64 -0
- agentseam/matrix_terms.py +55 -0
- agentseam/packaging.py +299 -0
- agentseam/packaging_data.py +278 -0
- agentseam/packaging_limits.py +80 -0
- agentseam/permissions.py +234 -0
- agentseam/permissions_data.py +234 -0
- agentseam/permissions_render.py +196 -0
- agentseam-0.1.0.dist-info/METADATA +295 -0
- agentseam-0.1.0.dist-info/RECORD +43 -0
- agentseam-0.1.0.dist-info/WHEEL +5 -0
- agentseam-0.1.0.dist-info/entry_points.txt +2 -0
- agentseam-0.1.0.dist-info/licenses/LICENSE +202 -0
- agentseam-0.1.0.dist-info/top_level.txt +1 -0
agentseam/__init__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""agentseam — the primitives layer for every coding agent.
|
|
2
|
+
|
|
3
|
+
Write one handler; run it on Claude Code, Cursor, VS Code Copilot and friends.
|
|
4
|
+
agentseam owns the per-agent differences: payload shapes, response dialects, config
|
|
5
|
+
file formats, and an explicit capability matrix that says what each agent can
|
|
6
|
+
actually enforce.
|
|
7
|
+
|
|
8
|
+
from agentseam import run, Decision
|
|
9
|
+
|
|
10
|
+
def handler(event):
|
|
11
|
+
if event.event == "pre_tool" and event.command == "rm -rf /":
|
|
12
|
+
return Decision.deny("no")
|
|
13
|
+
return Decision.allow()
|
|
14
|
+
|
|
15
|
+
run(handler)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
from . import adapters, bundler, instructions, packaging, permissions
|
|
21
|
+
from .contract import (
|
|
22
|
+
ALLOW,
|
|
23
|
+
ASK,
|
|
24
|
+
DENY,
|
|
25
|
+
EVENTS,
|
|
26
|
+
FILE_CHANGED,
|
|
27
|
+
INSTRUCTIONS_LOADED,
|
|
28
|
+
POST_TOOL,
|
|
29
|
+
PRE_COMPACT,
|
|
30
|
+
PRE_TOOL,
|
|
31
|
+
PROMPT_SUBMIT,
|
|
32
|
+
REWRITE,
|
|
33
|
+
SESSION_END,
|
|
34
|
+
SESSION_START,
|
|
35
|
+
STOP,
|
|
36
|
+
SUBAGENT_START,
|
|
37
|
+
SUBAGENT_STOP,
|
|
38
|
+
TOOL_FAILURE,
|
|
39
|
+
UNKNOWN,
|
|
40
|
+
Decision,
|
|
41
|
+
Event,
|
|
42
|
+
)
|
|
43
|
+
from .dispatch import degrade, handle, run
|
|
44
|
+
from .matrix import MATRIX, adapted_agents, agents, can_block, can_rewrite, capability, enforcement_level
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"run",
|
|
48
|
+
"handle",
|
|
49
|
+
"degrade",
|
|
50
|
+
"Event",
|
|
51
|
+
"Decision",
|
|
52
|
+
"EVENTS",
|
|
53
|
+
"adapters",
|
|
54
|
+
"bundler",
|
|
55
|
+
"instructions",
|
|
56
|
+
"packaging",
|
|
57
|
+
"permissions",
|
|
58
|
+
"ALLOW",
|
|
59
|
+
"DENY",
|
|
60
|
+
"ASK",
|
|
61
|
+
"REWRITE",
|
|
62
|
+
"MATRIX",
|
|
63
|
+
"agents",
|
|
64
|
+
"capability",
|
|
65
|
+
"can_block",
|
|
66
|
+
"can_rewrite",
|
|
67
|
+
"enforcement_level",
|
|
68
|
+
"adapted_agents",
|
|
69
|
+
"SESSION_START",
|
|
70
|
+
"SESSION_END",
|
|
71
|
+
"PROMPT_SUBMIT",
|
|
72
|
+
"PRE_TOOL",
|
|
73
|
+
"POST_TOOL",
|
|
74
|
+
"TOOL_FAILURE",
|
|
75
|
+
"PRE_COMPACT",
|
|
76
|
+
"STOP",
|
|
77
|
+
"SUBAGENT_START",
|
|
78
|
+
"SUBAGENT_STOP",
|
|
79
|
+
"INSTRUCTIONS_LOADED",
|
|
80
|
+
"FILE_CHANGED",
|
|
81
|
+
"UNKNOWN",
|
|
82
|
+
"__version__",
|
|
83
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Per-agent adapters: parse a vendor payload -> Event; speak a Decision in vendor dialect.
|
|
2
|
+
|
|
3
|
+
An adapter is the ONLY place vendor knowledge lives. Adding an agent = adding a module
|
|
4
|
+
here plus a matrix row; no consumer changes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from . import (
|
|
10
|
+
antigravity,
|
|
11
|
+
claude_code,
|
|
12
|
+
codex_cli,
|
|
13
|
+
cursor,
|
|
14
|
+
devin,
|
|
15
|
+
gemini_cli,
|
|
16
|
+
grok,
|
|
17
|
+
junie,
|
|
18
|
+
kimi_code,
|
|
19
|
+
tabnine,
|
|
20
|
+
vscode_copilot,
|
|
21
|
+
windsurf,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
ADAPTERS = {
|
|
25
|
+
antigravity.AGENT: antigravity,
|
|
26
|
+
claude_code.AGENT: claude_code,
|
|
27
|
+
codex_cli.AGENT: codex_cli,
|
|
28
|
+
cursor.AGENT: cursor,
|
|
29
|
+
devin.AGENT: devin,
|
|
30
|
+
gemini_cli.AGENT: gemini_cli,
|
|
31
|
+
grok.AGENT: grok,
|
|
32
|
+
junie.AGENT: junie,
|
|
33
|
+
kimi_code.AGENT: kimi_code,
|
|
34
|
+
tabnine.AGENT: tabnine,
|
|
35
|
+
vscode_copilot.AGENT: vscode_copilot,
|
|
36
|
+
windsurf.AGENT: windsurf,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get(agent):
|
|
41
|
+
try:
|
|
42
|
+
return ADAPTERS[agent]
|
|
43
|
+
except KeyError:
|
|
44
|
+
raise KeyError("no adapter for agent %r (have: %s)" % (agent, ", ".join(sorted(ADAPTERS))))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def detect(raw):
|
|
48
|
+
"""Best-effort agent identification from a raw payload.
|
|
49
|
+
|
|
50
|
+
Used by the universal dispatcher when the caller did not say which agent it is.
|
|
51
|
+
Returns an agent name or None; never guesses when two adapters both claim it.
|
|
52
|
+
"""
|
|
53
|
+
claims = [name for name, mod in sorted(ADAPTERS.items()) if mod.claims(raw)]
|
|
54
|
+
return claims[0] if len(claims) == 1 else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def shell_tools(agent):
|
|
58
|
+
"""Tool names a shell command arrives under, or () where none is established.
|
|
59
|
+
|
|
60
|
+
The one question a caller wiring a shell gate must answer, and the one whose wrong answer
|
|
61
|
+
is silent: a `matcher` naming a tool the vendor does not use matches nothing, so the hook
|
|
62
|
+
never fires and the install still reports success.
|
|
63
|
+
|
|
64
|
+
() is a claim in the usual sense here -- "not established", not "no shell tool". Guessing
|
|
65
|
+
is the failure this returns () to prevent: a sibling guardrail hardcodes "Bash" for four
|
|
66
|
+
vendors, which is right for the two that speak Claude Code's protocol and unverified for
|
|
67
|
+
the rest.
|
|
68
|
+
"""
|
|
69
|
+
return tuple(getattr(get(agent), "SHELL_TOOLS", ()))
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""PowerShell's one rule that breaks hook commands, shared by the vendors it affects.
|
|
2
|
+
|
|
3
|
+
Two adapters here need this and for the same reason, so the explanation lives once rather
|
|
4
|
+
than twice: a copy that drifts is how a vendor fact becomes two disagreeing vendor facts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def powershell_command(command):
|
|
11
|
+
"""`command` rewritten so PowerShell will actually run it.
|
|
12
|
+
|
|
13
|
+
A command line that BEGINS with a quoted path is parsed by PowerShell as a string
|
|
14
|
+
expression, not an invocation -- and a bare string followed by more arguments is a
|
|
15
|
+
parse error, so nothing runs at all. `&` is PowerShell's call operator, and it makes a
|
|
16
|
+
quoted path executable.
|
|
17
|
+
|
|
18
|
+
Witnessed live (Codex CLI 0.150.1, Windows, 2026-08-28): the quoted form failed with
|
|
19
|
+
"hook exited with code 1" on every event, and a `> file 2>&1` redirect appended to it
|
|
20
|
+
produced NO file -- proof the line never reached execution, since PowerShell sets up
|
|
21
|
+
redirection only for a command it could parse. Prefixing `&` ran the hook immediately.
|
|
22
|
+
A bare `python3 "..."` also works, because it parses in command mode; the difference is
|
|
23
|
+
the leading quote, not the interpreter.
|
|
24
|
+
|
|
25
|
+
Both vendors route hooks through PowerShell on Windows. Codex wraps them itself;
|
|
26
|
+
VS Code Copilot does it in hookExecutor.ts's getShellCommand, which spawns
|
|
27
|
+
`powershell.exe -ExecutionPolicy Bypass -NoProfile -NoLogo -Command <hookCommand>`
|
|
28
|
+
whenever ComSpec is cmd.exe -- i.e. by default. Each adapter passes the result through
|
|
29
|
+
its own per-platform override field, so the POSIX `command` keeps the exact quoted
|
|
30
|
+
interpreter path that installed the hook.
|
|
31
|
+
"""
|
|
32
|
+
return command if command.lstrip().startswith("&") else "& " + command
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Antigravity adapter.
|
|
2
|
+
|
|
3
|
+
Two things make this one different from every other adapter here.
|
|
4
|
+
|
|
5
|
+
**The payload does not name its event.** There is no `hookEventName` field, so the event has
|
|
6
|
+
to be inferred from shape. That inference has a genuine ambiguity in it: PreToolUse and
|
|
7
|
+
PostToolUse carry the same `toolCall` and `stepIdx`, separated only by PostToolUse's `error`
|
|
8
|
+
field, which is documented as empty rather than absent on success. So the tie is broken
|
|
9
|
+
*toward* PRE_TOOL, deliberately. Guessing post-tool on a pre-tool payload would skip the gate
|
|
10
|
+
and let the call through; guessing pre-tool on a post-tool payload only produces a decision
|
|
11
|
+
that Antigravity ignores, because PostToolUse output is `{}`. One direction fails open, the
|
|
12
|
+
other fails harmlessly.
|
|
13
|
+
|
|
14
|
+
`PreInvocation` and `PostInvocation` are left unmapped for the same reason taken to its
|
|
15
|
+
conclusion: their payloads are documented as *identical*, so nothing could tell them apart,
|
|
16
|
+
and neither has a canonical counterpart worth bending one into.
|
|
17
|
+
|
|
18
|
+
**The decision vocabulary is richer than ours.** PreToolUse accepts `allow`, `deny`, `ask`,
|
|
19
|
+
`force_ask` and `deny_unless_prior_grant`. agentseam's contract has three of those, so the
|
|
20
|
+
last two are reachable only by a handler writing Antigravity's dialect directly -- recorded
|
|
21
|
+
here rather than quietly dropped. `ask` is honoured, but respects a user's "Always Allow";
|
|
22
|
+
`force_ask` is the one that ignores cached permissions, and a handler that means "prompt
|
|
23
|
+
every time" is not getting it through this contract today.
|
|
24
|
+
|
|
25
|
+
Tool arguments are PascalCase (`CommandLine`, `TargetFile`, `CodeContent`), which is why the
|
|
26
|
+
field extraction below is explicit rather than a generic lookup.
|
|
27
|
+
|
|
28
|
+
Verified against Antigravity's hooks documentation (2026-08-26).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json as _json
|
|
34
|
+
|
|
35
|
+
from ..contract import ASK, DENY, POST_TOOL, PRE_TOOL, REWRITE, STOP, UNKNOWN, Event, degraded_from
|
|
36
|
+
|
|
37
|
+
AGENT = "antigravity"
|
|
38
|
+
|
|
39
|
+
#: Canonical -> the event key used in hooks.json.
|
|
40
|
+
REVERSE_EVENT_MAP = {PRE_TOOL: "PreToolUse", POST_TOOL: "PostToolUse", STOP: "Stop"}
|
|
41
|
+
|
|
42
|
+
#: Per-tool argument names. Antigravity's tools take PascalCase args that differ by tool,
|
|
43
|
+
#: so a guardrail asking "which file, what content" needs this table to get an answer.
|
|
44
|
+
_COMMAND_ARG = "CommandLine"
|
|
45
|
+
_PATH_ARGS = ("TargetFile", "AbsolutePath")
|
|
46
|
+
_CONTENT_ARGS = ("CodeContent", "ReplacementContent")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def claims(raw):
|
|
50
|
+
"""Structural: `conversationId` with `workspacePaths` is Antigravity's own envelope.
|
|
51
|
+
|
|
52
|
+
Cursor's near-equivalents are `conversation_id` and `workspace_roots`, so the two do not
|
|
53
|
+
collide. No event name is checked because the payload does not carry one.
|
|
54
|
+
"""
|
|
55
|
+
if not isinstance(raw, dict):
|
|
56
|
+
return False
|
|
57
|
+
return "conversationId" in raw and isinstance(raw.get("workspacePaths"), list)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _infer_event(raw):
|
|
61
|
+
"""Name the event from shape. See the module docstring for why ties go to PreToolUse."""
|
|
62
|
+
if "terminationReason" in raw or "fullyIdle" in raw:
|
|
63
|
+
return "Stop"
|
|
64
|
+
if isinstance(raw.get("toolCall"), dict):
|
|
65
|
+
return "PostToolUse" if "error" in raw else "PreToolUse"
|
|
66
|
+
# PreInvocation / PostInvocation are indistinguishable from each other and unmapped.
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _content_of(args):
|
|
71
|
+
for key in _CONTENT_ARGS:
|
|
72
|
+
if args.get(key):
|
|
73
|
+
return args[key]
|
|
74
|
+
chunks = args.get("ReplacementChunks")
|
|
75
|
+
if isinstance(chunks, list):
|
|
76
|
+
joined = "\n".join(str(c.get("ReplacementContent", "")) for c in chunks if isinstance(c, dict))
|
|
77
|
+
return joined or None
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse(raw):
|
|
82
|
+
name = _infer_event(raw)
|
|
83
|
+
call = raw.get("toolCall") if isinstance(raw.get("toolCall"), dict) else {}
|
|
84
|
+
args = call.get("args") if isinstance(call.get("args"), dict) else {}
|
|
85
|
+
roots = raw.get("workspacePaths") or []
|
|
86
|
+
path = None
|
|
87
|
+
for key in _PATH_ARGS:
|
|
88
|
+
if args.get(key):
|
|
89
|
+
path = args[key]
|
|
90
|
+
break
|
|
91
|
+
return Event(
|
|
92
|
+
AGENT,
|
|
93
|
+
# An event this adapter has no mapping for resolves to UNKNOWN, never to the
|
|
94
|
+
# nearest canonical one: relabelling it invites a guardrail to evaluate the
|
|
95
|
+
# wrong policy against it.
|
|
96
|
+
{"PreToolUse": PRE_TOOL, "PostToolUse": POST_TOOL, "Stop": STOP}.get(name, UNKNOWN),
|
|
97
|
+
tool=call.get("name"),
|
|
98
|
+
command=args.get(_COMMAND_ARG),
|
|
99
|
+
path=path,
|
|
100
|
+
content=_content_of(args),
|
|
101
|
+
output=raw.get("error") or None,
|
|
102
|
+
session_id=raw.get("conversationId"),
|
|
103
|
+
# The docstring's own pre/post correlation key -- PreToolUse and PostToolUse carry
|
|
104
|
+
# the same stepIdx. Left unplumbed, a handler correlating a gate decision with its
|
|
105
|
+
# post-tool result (timing, verifying a denied call produced no output, per-call
|
|
106
|
+
# audit trails) found tool_use_id always None on the one agent that names one.
|
|
107
|
+
tool_use_id=str(raw["stepIdx"]) if "stepIdx" in raw else None,
|
|
108
|
+
cwd=args.get("Cwd") or (roots[0] if roots else None),
|
|
109
|
+
raw=raw,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
#: Decision words this vendor accepts. PreToolUse takes the five in the docstring above;
|
|
114
|
+
#: Stop takes continue/stop, where anything but "continue" lets the stop happen.
|
|
115
|
+
DECISION_VOCABULARY = frozenset({"allow", "deny", "ask", "force_ask", "deny_unless_prior_grant", "continue", "stop"})
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def respond(decision, event):
|
|
119
|
+
name = _infer_event(event.raw or {})
|
|
120
|
+
|
|
121
|
+
if name == "PostToolUse":
|
|
122
|
+
# Documented as returning an empty object. Nothing here can undo the call.
|
|
123
|
+
return _json.dumps({}), 0
|
|
124
|
+
|
|
125
|
+
if name == "Stop":
|
|
126
|
+
# "continue" re-enters the execution loop; any other value lets the stop happen.
|
|
127
|
+
# ASK and REWRITE need the same degradation annotation the gate branch below gives
|
|
128
|
+
# them -- without it, "continue" with the handler's raw reason reads as though the
|
|
129
|
+
# handler asked to keep working, when it actually asked for confirmation or a
|
|
130
|
+
# change Stop cannot express either of.
|
|
131
|
+
if decision.outcome == ASK:
|
|
132
|
+
note = (
|
|
133
|
+
"Antigravity cannot modify a tool call"
|
|
134
|
+
if degraded_from(decision) == REWRITE
|
|
135
|
+
else "Antigravity cannot prompt at Stop"
|
|
136
|
+
)
|
|
137
|
+
reason = _because(decision.reason, note)
|
|
138
|
+
return _json.dumps({"decision": "continue", "reason": reason}), 0
|
|
139
|
+
if decision.outcome == REWRITE:
|
|
140
|
+
reason = _because(decision.reason, "Antigravity cannot modify a tool call")
|
|
141
|
+
return _json.dumps({"decision": "continue", "reason": reason}), 0
|
|
142
|
+
if decision.outcome == DENY:
|
|
143
|
+
return _json.dumps({"decision": "continue", "reason": decision.reason or "policy requires more work"}), 0
|
|
144
|
+
return _json.dumps({"decision": "stop"}), 0
|
|
145
|
+
|
|
146
|
+
if decision.outcome == REWRITE:
|
|
147
|
+
# No updatedInput equivalent: permissionOverrides widens permissions, it does not
|
|
148
|
+
# change arguments. Allowing the unmodified call through would be the wrong read.
|
|
149
|
+
return _json.dumps(
|
|
150
|
+
{"decision": "deny", "reason": _because(decision.reason, "Antigravity cannot modify a tool call")}
|
|
151
|
+
), 0
|
|
152
|
+
if decision.outcome == ASK:
|
|
153
|
+
if degraded_from(decision) == REWRITE:
|
|
154
|
+
# A rewrite the dispatcher reduced. Prompting would offer the user the
|
|
155
|
+
# *unmodified* call to approve, which is the thing the handler rejected.
|
|
156
|
+
return _json.dumps(
|
|
157
|
+
{"decision": "deny", "reason": _because(decision.reason, "Antigravity cannot modify a tool call")}
|
|
158
|
+
), 0
|
|
159
|
+
# A real ask is honoured, but a user's "Always Allow" still applies. force_ask is
|
|
160
|
+
# the variant that ignores cached permissions, and our contract cannot request it.
|
|
161
|
+
return _json.dumps({"decision": "ask", "reason": decision.reason or "confirmation required"}), 0
|
|
162
|
+
if decision.outcome == DENY:
|
|
163
|
+
return _json.dumps({"decision": "deny", "reason": decision.reason or "blocked by policy"}), 0
|
|
164
|
+
return _json.dumps({"decision": "allow"}), 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _because(reason, note):
|
|
168
|
+
return "%s (%s)" % (reason, note) if reason else note
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
#: The group name we own in hooks.json. Antigravity keys the file by hook *name*, which
|
|
172
|
+
#: gives ownership a natural home: our entries live under one key nobody else writes.
|
|
173
|
+
GROUP = "agentseam"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def hook_config(canonical_events, command, matcher=None):
|
|
177
|
+
group = {}
|
|
178
|
+
for ev in canonical_events:
|
|
179
|
+
name = REVERSE_EVENT_MAP.get(ev)
|
|
180
|
+
if not name:
|
|
181
|
+
continue
|
|
182
|
+
entry = {"hooks": [{"type": "command", "command": command}]}
|
|
183
|
+
if matcher:
|
|
184
|
+
entry["matcher"] = matcher
|
|
185
|
+
group.setdefault(name, []).append(entry)
|
|
186
|
+
return {GROUP: group}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
CONFIG_PATH = ".agents/hooks.json"
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Claude Code adapter.
|
|
2
|
+
|
|
3
|
+
Payload: {"tool_name", "tool_input", "session_id", "tool_use_id", "hook_event_name", ...}
|
|
4
|
+
Response: {"hookSpecificOutput": {"hookEventName", "permissionDecision", ...}} on stdout;
|
|
5
|
+
exit 2 also blocks. Verified live against Claude Code 2.1.245 (2026-08-25)."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ..contract import (
|
|
10
|
+
ALLOW,
|
|
11
|
+
ASK,
|
|
12
|
+
DENY,
|
|
13
|
+
FILE_CHANGED,
|
|
14
|
+
INSTRUCTIONS_LOADED,
|
|
15
|
+
POST_TOOL,
|
|
16
|
+
PRE_COMPACT,
|
|
17
|
+
PRE_TOOL,
|
|
18
|
+
PROMPT_SUBMIT,
|
|
19
|
+
REWRITE,
|
|
20
|
+
SESSION_END,
|
|
21
|
+
SESSION_START,
|
|
22
|
+
STOP,
|
|
23
|
+
SUBAGENT_START,
|
|
24
|
+
SUBAGENT_STOP,
|
|
25
|
+
TOOL_FAILURE,
|
|
26
|
+
UNKNOWN,
|
|
27
|
+
VOUCH,
|
|
28
|
+
Event,
|
|
29
|
+
tool_input_of,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
AGENT = "claude_code"
|
|
33
|
+
|
|
34
|
+
# vendor event name -> canonical
|
|
35
|
+
EVENT_MAP = {
|
|
36
|
+
"PreToolUse": PRE_TOOL,
|
|
37
|
+
"PostToolUse": POST_TOOL,
|
|
38
|
+
"PostToolUseFailure": TOOL_FAILURE,
|
|
39
|
+
"UserPromptSubmit": PROMPT_SUBMIT,
|
|
40
|
+
"SessionStart": SESSION_START,
|
|
41
|
+
"SessionEnd": SESSION_END,
|
|
42
|
+
"Stop": STOP,
|
|
43
|
+
"PreCompact": PRE_COMPACT,
|
|
44
|
+
"SubagentStart": SUBAGENT_START,
|
|
45
|
+
"SubagentStop": SUBAGENT_STOP,
|
|
46
|
+
# Both were claimed by the matrix long before they were mapped here, so an install for
|
|
47
|
+
# them wired nothing at all and said nothing about it. FileChanged fires when a watched
|
|
48
|
+
# file changes on disk (the matcher names the files); InstructionsLoaded fires when a
|
|
49
|
+
# CLAUDE.md or .claude/rules/*.md is read into context, at session start and again
|
|
50
|
+
# whenever one is loaded lazily.
|
|
51
|
+
"InstructionsLoaded": INSTRUCTIONS_LOADED,
|
|
52
|
+
"FileChanged": FILE_CHANGED,
|
|
53
|
+
}
|
|
54
|
+
REVERSE_EVENT_MAP = {v: k for k, v in EVENT_MAP.items()}
|
|
55
|
+
|
|
56
|
+
# Tools whose input carries file content rather than a shell command.
|
|
57
|
+
WRITE_TOOLS = ("Write", "Edit", "MultiEdit", "NotebookEdit")
|
|
58
|
+
|
|
59
|
+
#: The tool a shell command arrives under, and therefore what a PreToolUse `matcher` must say
|
|
60
|
+
#: to gate shell. A wrong value fails silently: it matches nothing, and the install looks fine.
|
|
61
|
+
SHELL_TOOLS = ("Bash",)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
#: Fields observed in real Claude Code payloads (live capture, v3.17.8 era, 2026-08-27) that
|
|
65
|
+
#: no other adapter's documented envelope lists. Positive evidence, not inferred absence --
|
|
66
|
+
#: which is the distinction that broke detection here. `prompt_id` was once treated as proof
|
|
67
|
+
#: a payload was NOT Claude Code's; Claude Code now sends it on nearly every event, so that
|
|
68
|
+
#: negative test rejected 38 of 42 real payloads and handed them to Devin instead.
|
|
69
|
+
OBSERVED_MARKERS = (
|
|
70
|
+
"transcript_path",
|
|
71
|
+
"permission_mode",
|
|
72
|
+
"stop_hook_active",
|
|
73
|
+
"agent_transcript_path",
|
|
74
|
+
"background_tasks",
|
|
75
|
+
"session_crons",
|
|
76
|
+
"custom_instructions",
|
|
77
|
+
"effort",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def looks_like_claude_code(raw):
|
|
82
|
+
"""True when the payload carries a field only Claude Code has been seen to send.
|
|
83
|
+
|
|
84
|
+
Imported by the adapters that share this envelope, so the discriminator lives in one
|
|
85
|
+
place and cannot drift into two disagreeing copies.
|
|
86
|
+
"""
|
|
87
|
+
return isinstance(raw, dict) and any(marker in raw for marker in OBSERVED_MARKERS)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def claims(raw):
|
|
91
|
+
"""True when this payload looks like Claude Code's shape."""
|
|
92
|
+
if not isinstance(raw, dict):
|
|
93
|
+
return False
|
|
94
|
+
if raw.get("hook_event_name") in EVENT_MAP:
|
|
95
|
+
# Codex reuses tool_input but adds turn identifiers, and Devin reuses the whole
|
|
96
|
+
# event vocabulary but adds a per-turn prompt_id. Claiming either would make
|
|
97
|
+
# detect() ambiguous, and an unidentified payload is allowed through.
|
|
98
|
+
# Kimi Code CLI is Claude Code's envelope exactly -- same PascalCase events, same
|
|
99
|
+
# snake_case fields -- and names itself only in client_type.
|
|
100
|
+
if raw.get("client_type") not in (None, "claude_code"):
|
|
101
|
+
return False
|
|
102
|
+
# Junie reuses this whole event vocabulary on purpose -- it says so -- and sends
|
|
103
|
+
# project_path, which Claude Code does not. Without this the two are one payload.
|
|
104
|
+
# Foreign nameplates: Codex's turn_id, Junie's project_path, Tabnine's timestamp.
|
|
105
|
+
# None appears in any real Claude Code payload observed live, and each is the only
|
|
106
|
+
# documented thing separating that vendor's identically-named events from ours.
|
|
107
|
+
if "turn_id" in raw or "project_path" in raw or "timestamp" in raw:
|
|
108
|
+
return False
|
|
109
|
+
# `prompt_id` used to be treated as proof this was Devin's payload, not ours. Claude
|
|
110
|
+
# Code now sends it too, so the field alone settles nothing: it is only Devin's when
|
|
111
|
+
# nothing we have actually observed from Claude Code is alongside it.
|
|
112
|
+
if "prompt_id" in raw and not looks_like_claude_code(raw):
|
|
113
|
+
return False
|
|
114
|
+
return True
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def parse(raw):
|
|
119
|
+
ti = raw.get("tool_input")
|
|
120
|
+
# A guard that crashes is a guard that allows: dispatch only wraps the JSON decode, so
|
|
121
|
+
# an exception here kills the hook with exit 1, which most vendors treat as a
|
|
122
|
+
# non-blocking error and let the call through. tool_input is whatever the agent chose
|
|
123
|
+
# to serialise, so it is not ours to assume the shape of.
|
|
124
|
+
ti = tool_input_of(ti)
|
|
125
|
+
tool = raw.get("tool_name")
|
|
126
|
+
# new_source is NotebookEdit's cell body -- the tool is in WRITE_TOOLS, so claiming to
|
|
127
|
+
# handle it while dropping its content is an internal contradiction, not a vendor guess.
|
|
128
|
+
content = ti.get("content") or ti.get("new_string") or ti.get("new_source") or None
|
|
129
|
+
if content is None and isinstance(ti.get("edits"), list):
|
|
130
|
+
joined = "\n".join(str(e.get("new_string", "")) for e in ti["edits"] if isinstance(e, dict))
|
|
131
|
+
content = joined or None
|
|
132
|
+
out = raw.get("tool_output")
|
|
133
|
+
if isinstance(out, (dict, list)):
|
|
134
|
+
import json as _json
|
|
135
|
+
|
|
136
|
+
out = _json.dumps(out)
|
|
137
|
+
return Event(
|
|
138
|
+
AGENT,
|
|
139
|
+
# An event this adapter has no mapping for resolves to UNKNOWN, never to the
|
|
140
|
+
# nearest canonical one: relabelling it invites a guardrail to evaluate the
|
|
141
|
+
# wrong policy against it.
|
|
142
|
+
EVENT_MAP.get(raw.get("hook_event_name"), UNKNOWN),
|
|
143
|
+
tool=tool,
|
|
144
|
+
command=ti.get("command"),
|
|
145
|
+
# InstructionsLoaded and FileChanged carry no tool_input at all -- file_path (and,
|
|
146
|
+
# for InstructionsLoaded, content) sit at the top level instead, per the project's
|
|
147
|
+
# own recorded example payloads (examples/generated/claude_code.md).
|
|
148
|
+
path=ti.get("file_path") or ti.get("path") or ti.get("notebook_path") or raw.get("file_path"),
|
|
149
|
+
content=content or raw.get("content"),
|
|
150
|
+
output=out,
|
|
151
|
+
prompt=raw.get("prompt"),
|
|
152
|
+
session_id=raw.get("session_id"),
|
|
153
|
+
tool_use_id=raw.get("tool_use_id"),
|
|
154
|
+
cwd=raw.get("cwd"),
|
|
155
|
+
raw=raw,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
#: Events that read a TOP-LEVEL {"decision": "block", "reason": ...} and ignore
|
|
160
|
+
#: hookSpecificOutput.permissionDecision entirely. Established live against 2.1.247
|
|
161
|
+
#: (2026-08-28) -- two reads of the vendor page gave contradictory answers. See respond().
|
|
162
|
+
_BLOCK_DIALECT_EVENTS = (PROMPT_SUBMIT, STOP)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _refusal_reason(decision):
|
|
166
|
+
"""One reason string for the events that can only block -- no ask, no rewrite.
|
|
167
|
+
|
|
168
|
+
Both degrade to a block with the degradation named rather than being dropped: silence
|
|
169
|
+
at a blocking event is the dispatcher's allow, which is not what the caller asked for.
|
|
170
|
+
"""
|
|
171
|
+
reason = decision.reason or "blocked by policy"
|
|
172
|
+
if decision.outcome == ASK:
|
|
173
|
+
return reason + " (confirmation requested; this event cannot prompt, so it blocks)"
|
|
174
|
+
if decision.outcome == REWRITE:
|
|
175
|
+
return reason + " (input rewrite requested; this event cannot modify input, so it blocks)"
|
|
176
|
+
return reason
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
#: Decision words this vendor accepts. allow/deny/ask are permissionDecision's, honoured at
|
|
180
|
+
#: pre_tool; "block" is the top-level dialect prompt_submit and stop read. Established live
|
|
181
|
+
#: against 2.1.247 (2026-08-28).
|
|
182
|
+
DECISION_VOCABULARY = frozenset({"allow", "deny", "ask", "block"})
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
#: hookSpecificOutput.additionalContext -- docs-basis, NOT the live-verified 2.1.247
|
|
186
|
+
#: experiment below. code.claude.com/docs/en/hooks documents this as how a hook injects text
|
|
187
|
+
#: into Claude's own context, explicitly nested (top-level is silently ignored), with a
|
|
188
|
+
#: SessionStart example naming it and a UserPromptSubmit section for the same purpose.
|
|
189
|
+
def _additional_context_output(event, context):
|
|
190
|
+
return {
|
|
191
|
+
"hookSpecificOutput": {
|
|
192
|
+
"hookEventName": REVERSE_EVENT_MAP.get(event.event, "PreToolUse"),
|
|
193
|
+
"additionalContext": context,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def respond(decision, event):
|
|
199
|
+
"""(stdout_text, exit_code) for this decision -- three dialects, not one.
|
|
200
|
+
|
|
201
|
+
Until 2026-08-28 this emitted hookSpecificOutput.permissionDecision at EVERY event.
|
|
202
|
+
A live experiment against Claude Code 2.1.247 settled what each event actually reads,
|
|
203
|
+
by wiring one candidate shape per trial and watching the agent rather than the hook:
|
|
204
|
+
|
|
205
|
+
event {"decision": "block"} hookSpecificOutput exit 2
|
|
206
|
+
UserPromptSubmit honoured IGNORED honoured
|
|
207
|
+
Stop honoured IGNORED honoured
|
|
208
|
+
PreToolUse -- honoured --
|
|
209
|
+
|
|
210
|
+
So every prompt_submit and stop deny this library has ever produced on its most-used
|
|
211
|
+
adapter was silently discarded: the handler refused, the dispatcher reported a block,
|
|
212
|
+
and the prompt reached the model anyway. At prompt_submit the trial prompt asked the
|
|
213
|
+
agent to write a marker file and the file appeared; at stop the agent carried on and
|
|
214
|
+
the Stop hook re-fired with stop_hook_active set. Both signals are things the agent
|
|
215
|
+
did, not things the probe claimed.
|
|
216
|
+
|
|
217
|
+
exit 2 works at both, and is deliberately NOT used: it collapses to 1 under the
|
|
218
|
+
PowerShell wrapper some vendors apply, and it leaks the full hook command line into the
|
|
219
|
+
UI where the JSON form does not. The JSON block also carries the reason into context.
|
|
220
|
+
|
|
221
|
+
pre_tool keeps hookSpecificOutput.permissionDecision, and that was verified in the same
|
|
222
|
+
round rather than assumed: a deny there blocked the Write outright, and the agent's next
|
|
223
|
+
Bash call too, so the gate fires for every tool. It must keep this shape in any case --
|
|
224
|
+
it is the only one carrying updatedInput, which rewrite depends on. Everything else is
|
|
225
|
+
observation-only in this row and gets silence: a verdict there was never read.
|
|
226
|
+
|
|
227
|
+
A bare ALLOW is SILENCE, not permissionDecision:"allow".
|
|
228
|
+
|
|
229
|
+
"The handler has no objection" and "skip the user's own permission prompt" are different
|
|
230
|
+
statements, and only the first is what a guardrail means. The vendor's documentation
|
|
231
|
+
settles what silence does, verbatim: "Exit code 0 with no output means the hook has no
|
|
232
|
+
decision to report, so the tool call continues through the normal permission flow."
|
|
233
|
+
What an explicit "allow" does there is NOT documented -- so the recorded option keeps
|
|
234
|
+
the user's own protection. VS Code's languageModelToolsService, read from source, returns
|
|
235
|
+
`autoConfirmed: ConfirmationNotNeeded` on exactly this value -- which VOUCH exists to
|
|
236
|
+
speak on purpose (see below, and allow_semantics.VOUCH_SPEAKS) on the two vendors this is
|
|
237
|
+
established for. REWRITE still sends an explicit allow, since updatedInput is the only
|
|
238
|
+
way to express one and approving the *substituted* call is what was asked for.
|
|
239
|
+
"""
|
|
240
|
+
import json as _json
|
|
241
|
+
|
|
242
|
+
if event.event in _BLOCK_DIALECT_EVENTS:
|
|
243
|
+
# VOUCH has no word here either -- only ever spoken at pre_tool below -- so it is a
|
|
244
|
+
# louder allow this dialect cannot hear.
|
|
245
|
+
out = {} if decision.outcome in (ALLOW, VOUCH) else {"decision": "block", "reason": _refusal_reason(decision)}
|
|
246
|
+
# additionalContext is a second, independent key; only UserPromptSubmit of this pair
|
|
247
|
+
# is documented to take it.
|
|
248
|
+
if event.event == PROMPT_SUBMIT and decision.context:
|
|
249
|
+
out.update(_additional_context_output(event, decision.context))
|
|
250
|
+
return (_json.dumps(out), 0) if out else ("", 0)
|
|
251
|
+
|
|
252
|
+
if event.event == SESSION_START:
|
|
253
|
+
# Silent regardless of outcome (no block/rewrite claimed here) except for context.
|
|
254
|
+
if decision.context:
|
|
255
|
+
return _json.dumps(_additional_context_output(event, decision.context)), 0
|
|
256
|
+
return "", 0
|
|
257
|
+
|
|
258
|
+
if event.event != PRE_TOOL:
|
|
259
|
+
return "", 0
|
|
260
|
+
|
|
261
|
+
if decision.outcome == ALLOW:
|
|
262
|
+
return "", 0
|
|
263
|
+
|
|
264
|
+
out = {"hookEventName": REVERSE_EVENT_MAP.get(event.event, "PreToolUse")}
|
|
265
|
+
if decision.outcome == VOUCH:
|
|
266
|
+
# Reaches here undegraded only because allow_semantics.VOUCH_SPEAKS names
|
|
267
|
+
# claude_code (see dispatch.degrade()) -- the word a bare ALLOW withholds above.
|
|
268
|
+
out["permissionDecision"] = "allow"
|
|
269
|
+
if decision.reason:
|
|
270
|
+
out["permissionDecisionReason"] = decision.reason
|
|
271
|
+
elif decision.outcome == DENY:
|
|
272
|
+
out["permissionDecision"] = "deny"
|
|
273
|
+
out["permissionDecisionReason"] = decision.reason or "blocked"
|
|
274
|
+
elif decision.outcome == ASK:
|
|
275
|
+
out["permissionDecision"] = "ask"
|
|
276
|
+
out["permissionDecisionReason"] = decision.reason or "confirmation required"
|
|
277
|
+
elif decision.outcome == REWRITE:
|
|
278
|
+
out["permissionDecision"] = "allow"
|
|
279
|
+
out["updatedInput"] = decision.updated_input
|
|
280
|
+
if decision.reason:
|
|
281
|
+
out["permissionDecisionReason"] = decision.reason
|
|
282
|
+
return _json.dumps({"hookSpecificOutput": out}), 0
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def hook_config(canonical_events, command, matcher=None):
|
|
286
|
+
"""A settings.json `hooks` fragment wiring `command` for these canonical events."""
|
|
287
|
+
hooks = {}
|
|
288
|
+
for ev in canonical_events:
|
|
289
|
+
name = REVERSE_EVENT_MAP.get(ev)
|
|
290
|
+
if not name:
|
|
291
|
+
continue
|
|
292
|
+
entry = {"hooks": [{"type": "command", "command": command}]}
|
|
293
|
+
if matcher:
|
|
294
|
+
entry["matcher"] = matcher
|
|
295
|
+
hooks.setdefault(name, []).append(entry)
|
|
296
|
+
return {"hooks": hooks}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
CONFIG_PATH = ".claude/settings.json"
|