Skip to content

Commit 18adb7f

Browse files
Merge pull request #67 from open-coder-ai/claude/agent-memory-governance-gu5n6z
fix(adapters): tool_input is not always an object, and the string form blinded the guard
2 parents 84e304b + d7302da commit 18adb7f

18 files changed

Lines changed: 234 additions & 31 deletions

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,35 @@ versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
88

9+
### Fixed
10+
- **`tool_input` is not always an object, and the string form blinded every guard.** Live
11+
capture on VS Code Copilot (2026-08-29), the first real payloads this adapter has ever
12+
had: the same `Edit` tool sent `tool_input` as an **object** in one run and as a
13+
**129-character JSON string** in another, the two runs routed to different models. Against
14+
the object the parser reported the file and its content; against the string it reported
15+
neither, so a policy denying on secret content saw an empty write and allowed it. The
16+
`isinstance` guard added when `parse()` was made total stopped the crash -- and a crash is
17+
an allow -- but resolved the string to `{}`, which is the same blindness without the
18+
traceback. `contract.tool_input_of` now decodes it, and every adapter reads through it.
19+
Pinned by an invariant stated as an equivalence -- the string form must parse to what the
20+
object form parses to -- so an adapter whose vendor does not use `tool_input` is not
21+
accused of a bug, and only reading one shape and not the other is forbidden.
22+
23+
### Changed
24+
- **VS Code Copilot's row moves from `vendor-source` to `live-partial`.** The capture
25+
corrected the write-tool vocabulary the adapter had explicitly deferred to a live round:
26+
the tools are `Edit`, `Read` and `Glob`, sending `path`/`old_str`/`new_str` -- not the
27+
`create_file`/`edit_file`/`apply_patch` a filed finding had assumed. Those keys were
28+
already in the generic parse chain, so the guessed *names* never mattered; the wire
29+
*shape* did. Five events observed; `initial_prompt` (SessionStart) and `stop_reason`
30+
(Stop) appear in no vendor doc read here.
31+
- **The redactor lost structure inside JSON-encoded strings.** It recorded the `Edit`
32+
payload as `<str:129>`, which reads like an id, so a capture run specifically to learn
33+
which keys the write tool carries came back saying nothing. A string that parses to a dict
34+
or list is now unwrapped and redacted as the structure it is, marked `<json-string>` so a
35+
reader can tell the vendor sent a string. Nothing is loosened: a test puts a secret inside
36+
an embedded field and asserts it does not survive.
37+
938
### Changed
1039
- **Cursor's "silence blocks" question is settled live, and the answer is a mechanism, not a
1140
rule.** Two trials on Cursor 3.17.8 (Windows, 2026-08-28) of one `beforeShellExecution`

examples/generated/README.md

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/generated/vscode_copilot.md

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/agentseam/adapters/claude_code.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
TOOL_FAILURE,
2727
UNKNOWN,
2828
Event,
29+
tool_input_of,
2930
)
3031

3132
AGENT = "claude_code"
@@ -116,7 +117,7 @@ def parse(raw):
116117
# an exception here kills the hook with exit 1, which most vendors treat as a
117118
# non-blocking error and let the call through. tool_input is whatever the agent chose
118119
# to serialise, so it is not ours to assume the shape of.
119-
ti = ti if isinstance(ti, dict) else {}
120+
ti = tool_input_of(ti)
120121
tool = raw.get("tool_name")
121122
# new_source is NotebookEdit's cell body -- the tool is in WRITE_TOOLS, so claiming to
122123
# handle it while dropping its content is an internal contradiction, not a vendor guess.

src/agentseam/adapters/codex_cli.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
SUBAGENT_STOP,
4545
UNKNOWN,
4646
Event,
47+
tool_input_of,
4748
)
4849

4950
# Re-exported: callers and tests address this through the adapter that needs it, and the
@@ -124,9 +125,7 @@ def parse(raw):
124125
imply a coverage this adapter does not have. `content`/`file_path`/`path` are kept only
125126
as a generic fallback for MCP tools, whose tool_input this capture did not exercise.
126127
"""
127-
ti = raw.get("tool_input") or {}
128-
if not isinstance(ti, dict):
129-
ti = {}
128+
ti = tool_input_of(raw.get("tool_input"))
130129
content = ti.get("content")
131130
return Event(
132131
AGENT,

src/agentseam/adapters/cursor.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
UNKNOWN,
4747
Event,
4848
degraded_from,
49+
tool_input_of,
4950
)
5051

5152
AGENT = "cursor"
@@ -158,7 +159,7 @@ def parse(raw):
158159
# unknown event gets reported as the gate.
159160
name = "afterFileEdit" if isinstance(raw.get("edits"), list) else "beforeShellExecution"
160161
ti = raw.get("tool_input")
161-
ti = ti if isinstance(ti, dict) else {}
162+
ti = tool_input_of(ti)
162163
command = raw.get("command") or ti.get("command")
163164
# preToolUse nests the target inside tool_input; the file-scoped hooks put it at the
164165
# top level. Reading only one of the two leaves a guardrail asking "which file?" with
@@ -167,8 +168,7 @@ def parse(raw):
167168
return Event(
168169
AGENT,
169170
# An event this adapter has no mapping for resolves to UNKNOWN, never to the
170-
# nearest canonical one: relabelling it invites a guardrail to evaluate the
171-
# wrong policy against it.
171+
# nearest canonical one: relabelling it invites a guardrail to evaluate the wrong policy against it.
172172
EVENT_MAP.get(name, UNKNOWN),
173173
tool=raw.get("tool_name") or name,
174174
command=command,

src/agentseam/adapters/devin.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
UNKNOWN,
4747
Event,
4848
degraded_from,
49+
tool_input_of,
4950
)
5051
from .claude_code import looks_like_claude_code
5152

@@ -110,7 +111,7 @@ def parse(raw):
110111
# an exception here kills the hook with exit 1, which most vendors treat as a
111112
# non-blocking error and let the call through. tool_input is whatever the agent chose
112113
# to serialise, so it is not ours to assume the shape of.
113-
ti = ti if isinstance(ti, dict) else {}
114+
ti = tool_input_of(ti)
114115
content = ti.get("content") or ti.get("new_string") or None
115116
out = raw.get("tool_output")
116117
if isinstance(out, (dict, list)):

src/agentseam/adapters/gemini_cli.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
STOP,
2828
UNKNOWN,
2929
Event,
30+
tool_input_of,
3031
)
3132
from .claude_code import looks_like_claude_code
3233

@@ -95,7 +96,7 @@ def parse(raw):
9596
# an exception here kills the hook with exit 1, which most vendors treat as a
9697
# non-blocking error and let the call through. tool_input is whatever the agent chose
9798
# to serialise, so it is not ours to assume the shape of.
98-
ti = ti if isinstance(ti, dict) else {}
99+
ti = tool_input_of(ti)
99100
tool = raw.get("tool_name")
100101
content = None
101102
if tool in WRITE_TOOLS:

src/agentseam/adapters/grok.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
UNKNOWN,
4545
Event,
4646
degraded_from,
47+
tool_input_of,
4748
)
4849

4950
AGENT = "grok"
@@ -94,7 +95,7 @@ def claims(raw):
9495

9596
def parse(raw):
9697
ti = raw.get("toolInput")
97-
ti = ti if isinstance(ti, dict) else {}
98+
ti = tool_input_of(ti)
9899
content = ti.get("content") or ti.get("new_string") or None
99100
out = raw.get("toolOutput")
100101
if isinstance(out, (dict, list)):

src/agentseam/adapters/junie.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
STOP,
5151
UNKNOWN,
5252
Event,
53+
tool_input_of,
5354
)
5455

5556
AGENT = "junie"
@@ -89,7 +90,7 @@ def claims(raw):
8990

9091
def parse(raw):
9192
ti = raw.get("tool_input")
92-
ti = ti if isinstance(ti, dict) else {}
93+
ti = tool_input_of(ti)
9394
# The docstring stakes everything on Junie's field names following Claude Code's wire
9495
# protocol exactly, so MultiEdit's edits[].new_string and NotebookEdit's new_source get
9596
# the same fallback chain claude_code.parse uses -- not a guess, a claim we already made.

0 commit comments

Comments
 (0)