Skip to content

Commit 1ea3d3e

Browse files
committed
Fix runtime scanner toy harness false positive
1 parent 76cc8ce commit 1ea3d3e

5 files changed

Lines changed: 70 additions & 10 deletions

File tree

docs/RELEASE_CHECKLIST.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ python examples/runtime_toy_agent.py --no-warmup
3232
The poisoned scan-path command and poisoned one-shot runtime scan command should
3333
exit with code `2` when run with `--fail-on block`. The JSONL server should keep
3434
the process alive and report `gate.exit_code=2` in the per-line JSON result.
35-
The toy-agent harness should block the poisoned fake filesystem call before
36-
execution.
35+
The toy-agent harness should allow the safe calculator call and block the
36+
poisoned fake filesystem call before execution.
3737

3838
## Regression suites
3939

docs/RUNTIME_HOOKS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ should scan:
1111
- tool-call inputs/arguments before the tool runs;
1212
- tool responses/results before the agent trusts or summarizes the output.
1313

14+
For runtime tool inputs and responses, the activation probe scans the body of
15+
the event, such as `arguments` or `response`. Wrapper metadata such as
16+
`tool_name` is preserved in the result subject for reporting, but is not mixed
17+
into the scanned text.
18+
1419
## Commands
1520

1621
Normalize without loading the model:

examples/runtime_toy_agent.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,15 +95,24 @@ def blocked(result: dict[str, Any]) -> bool:
9595
return DECISION_RANK.get(decision, 3) >= DECISION_RANK["block"]
9696

9797

98+
def decision_of(result: dict[str, Any]) -> str:
99+
return str(result.get("max_decision", "quarantine"))
100+
101+
98102
def summarize_gate(label: str, result: dict[str, Any]) -> None:
99-
decision = result.get("max_decision", "unknown")
103+
decision = decision_of(result)
100104
score = None
101105
rows = result.get("results") or []
102106
if rows:
103107
risk = rows[0].get("risk") or {}
104108
score = risk.get("risk_score")
105109
score_text = "" if score is None else f" risk={float(score):.3f}"
110+
error_text = ""
111+
if result.get("error"):
112+
error_text = f" error={result['error']}"
106113
print(f"{label}: decision={decision}{score_text}")
114+
if error_text:
115+
print(f"{label}:{error_text}")
107116

108117

109118
def gate_tool_call(
@@ -112,20 +121,21 @@ def gate_tool_call(
112121
label: str,
113122
tool: ToyTool,
114123
arguments: dict[str, Any],
115-
) -> bool:
124+
) -> tuple[bool, str]:
116125
event = {
117126
"event_type": "before_tool_call",
118127
"tool_name": tool.name,
119128
"arguments": arguments,
120129
}
121130
result = scanner.scan(event)
131+
decision = decision_of(result)
122132
summarize_gate(label, result)
123133
if blocked(result):
124134
print(f"{label}: blocked before fake tool execution")
125-
return False
135+
return False, decision
126136
output = tool.run(arguments)
127137
print(f"{label}: fake tool output={json.dumps(output, ensure_ascii=False)}")
128-
return True
138+
return True, decision
129139

130140

131141
def main(argv: list[str] | None = None) -> int:
@@ -149,13 +159,13 @@ def main(argv: list[str] | None = None) -> int:
149159
calculator = ToyTool("calculator.add", "Adds two integers and returns the sum.")
150160
filesystem = ToyTool("filesystem.read", "Reads a file path inside this fake sandbox harness.")
151161

152-
safe_ok = gate_tool_call(
162+
safe_ok, safe_decision = gate_tool_call(
153163
scanner,
154164
label="safe calculator call",
155165
tool=calculator,
156-
arguments={"a": 2, "b": 3, "reason": "Add two numbers for a toy demo."},
166+
arguments={"a": 2, "b": 3},
157167
)
158-
poison_ok = gate_tool_call(
168+
poison_ok, poison_decision = gate_tool_call(
159169
scanner,
160170
label="poisoned filesystem call",
161171
tool=filesystem,
@@ -176,9 +186,15 @@ def main(argv: list[str] | None = None) -> int:
176186
response_result = scanner.scan(response_event)
177187
summarize_gate("poisoned tool response", response_result)
178188

189+
if safe_decision != "allow":
190+
print(f"Unexpected: the safe toy call decision was {safe_decision}, expected allow.")
191+
return 1
179192
if not safe_ok:
180193
print("Unexpected: the safe toy call was blocked.")
181194
return 1
195+
if poison_decision != "block":
196+
print(f"Unexpected: the poisoned toy call decision was {poison_decision}, expected block.")
197+
return 1
182198
if poison_ok:
183199
print("Unexpected: the poisoned toy call was allowed.")
184200
return 1

intentprobe/scanner/hook.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,15 @@ def runtime_part_subject(
206206
) -> ScanSubject:
207207
event = runtime_event_name(payload)
208208
tool_name = tool_name_from_payload(payload)
209+
if kind in {"runtime_tool_input", "runtime_tool_response"}:
210+
return ScanSubject(
211+
subject_id=subject_id,
212+
kind=kind,
213+
name=tool_name,
214+
source=first_string(payload, ("source", "origin", "scope")),
215+
path=first_string(payload, ("path", "file", "file_path")),
216+
text=pretty_subject_text(value),
217+
)
209218
part_payload = {
210219
"kind": kind,
211220
"event_type": event,

research/activation_scanner_hook_regression.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,41 @@ def main(argv: list[str] | None = None) -> int:
106106
runtime_subject["subject"]["kind"] == "runtime_tool_input",
107107
f"runtime subject kind was not preserved: {runtime_subject['subject']['kind']}",
108108
)
109-
assert_true("filesystem.read" in runtime_text, "runtime tool name was lost")
109+
assert_true(runtime_subject["subject"]["name"] == "filesystem.read", "runtime tool name was lost from metadata")
110+
assert_true("filesystem.read" not in runtime_text, "runtime tool name leaked into scanned runtime body")
111+
assert_true("event_type" not in runtime_text, "runtime wrapper event_type leaked into scanned runtime body")
110112
assert_true("api_key" in runtime_text, "runtime secret key name was lost")
111113
assert_true("runtime-secret-that-must-not-appear" not in runtime_text, "runtime secret value was not redacted")
112114
checks.append({"name": "normalize_runtime_tool_input_redacts_values", "passed": True})
113115

116+
safe_runtime_payload = {
117+
"event_type": "before_tool_call",
118+
"tool_name": "calculator.add",
119+
"arguments": {
120+
"a": 2,
121+
"b": 3,
122+
},
123+
}
124+
safe_runtime_normalized, _safe_runtime_normalize_result = run_hook(
125+
[
126+
"normalize",
127+
"--input-format",
128+
"json",
129+
"--text",
130+
json.dumps(safe_runtime_payload),
131+
]
132+
)
133+
safe_runtime_subject = safe_runtime_normalized["subjects"][0]
134+
safe_runtime_text = safe_runtime_subject["text"]
135+
assert_true(
136+
safe_runtime_subject["subject"]["name"] == "calculator.add",
137+
"safe runtime tool name was not preserved in metadata",
138+
)
139+
assert_true('"a": 2' in safe_runtime_text and '"b": 3' in safe_runtime_text, "safe runtime arguments were not preserved")
140+
assert_true("calculator.add" not in safe_runtime_text, "safe runtime tool name leaked into scanned body")
141+
assert_true("before_tool_call" not in safe_runtime_text, "safe runtime event name leaked into scanned body")
142+
checks.append({"name": "normalize_runtime_tool_input_scans_body_only", "passed": True})
143+
114144
nested_runtime_payload = {
115145
"event_type": "before_tool_call",
116146
"tool_call": {

0 commit comments

Comments
 (0)