feat(cursor): port crash-report read routing to preToolUse

The second capability recorded as "omitted" without rationale. Cursor supports
it: PreToolUseRequestQuery carries tool_name and tool_input, and
PreToolUseRequestResponse accepts additional_context, so the canonical
PreToolUse(Read) crash routing maps directly onto preToolUse with a Read
matcher. Verified against a live Cursor 3.17.8 session — asked to read a
Sample.ips, the model reported being advised to use the axiom_xcsym_crash MCP
tool, and the read was not gated.

The canonical hints name the bare `xcsym` binary, which a Cursor install cannot
run — the plugin routes xcsym through MCP. Shipping them verbatim would have
told the model to run something that is not there, which is the likeliest
reason this looked unportable. The adapter maps the four invocation forms onto
the MCP tool and fails closed if any backticked `xcsym` survives: no hint is
strictly better than a wrong one.

No `permission` field is emitted, so the plugin never gates a read, consistent
with the documented position that no Axiom hook is a permission boundary. The
hint interpolates the file path, so the adapter bounds its length and rejects
control characters before it reaches the model's turn.

Contract disposition moves from "omitted" to "preToolUse.additional_context".
This commit is contained in:
Charles Wiltgen
2026-08-23 13:09:08 -07:00
parent a0cea09a5c
commit 3bf999dfc3
15 changed files with 384 additions and 14 deletions
+1
View File
@@ -116,6 +116,7 @@ Confirm Cursor shows the plugin as installed from the local marketplace and that
| No-argument command | Run one `/axiom-*` command without arguments | Native command appears and follows its translated workflow | _pending_ |
| Argument command | Run one argument-bearing command with benign and injection-oriented text | Arguments remain task input, not shell interpolation or authorization | _pending_ |
| Session hook | Start Apple-positive and non-Apple sessions | Compact context appears only where project detection allows it | _pending_ |
| Crash read hook | Open an `.ips` path, then an ordinary source file | Crash path yields advisory context naming the `axiom_xcsym_crash` MCP tool and never a bare `xcsym` command; ordinary file yields nothing; neither read is gated | _pending_ |
| Prompt router hook | Send an Apple-platform prompt in an Apple project, then the same prompt in a non-Apple one | Router guidance is injected only in the Apple project; the model names the matched skill | _pending_ |
| Shell hook | Exercise a fixture that emits a known Axiom hint | Advisory `additional_context`; no permission or denial field | _pending_ |
| Write hook | Exercise a Swift write fixture | Post-edit advisory context only; the edit is not blocked or undone | _pending_ |
+7
View File
@@ -13,6 +13,13 @@
"timeout": 5
}
],
"preToolUse": [
{
"command": "python3 ./scripts/cursor-hook-adapter.py pretool-read",
"matcher": "Read",
"timeout": 5
}
],
"postToolUse": [
{
"command": "python3 ./scripts/cursor-hook-adapter.py post-shell",
+2 -2
View File
@@ -405,7 +405,7 @@
"dispositions": {
"PostToolUse(Bash)": "postToolUse.additional_context",
"PostToolUse(Write|Edit)": "postToolUse.additional_context",
"PreToolUse(Read)": "omitted",
"PreToolUse(Read)": "preToolUse.additional_context",
"SessionStart": "sessionStart.additional_context",
"SubagentStart": "prompt",
"UserPromptSubmit": "beforeSubmitPrompt.additional_context",
@@ -1285,7 +1285,7 @@
"owner": null,
"event": "PreToolUse",
"matcher": "Read",
"disposition": "omitted",
"disposition": "preToolUse.additional_context",
"warning": null,
"advisory": null
},
+13 -8
View File
@@ -313,8 +313,8 @@
},
{
"path": "hooks/hooks.json",
"sha256": "d63d2468b177b85a62f4b1fbc5d6d2b9646eb4a26ac059d6020e2c514698200d",
"bytes": 635
"sha256": "e84521fd58405fdedef4632c493d5d1b4f8cca99cca171ceec53e86c07646083",
"bytes": 802
},
{
"path": "mcp.json",
@@ -323,19 +323,24 @@
},
{
"path": "reports/capability-disposition.json",
"sha256": "e860f887a730dda802b98138598154b5b33a3fdd51407bffd82c2931893e509f",
"bytes": 35384
"sha256": "51531c8f398bb98a5df45e63235315f1281edd676693e8381ae55127cc55409c",
"bytes": 35428
},
{
"path": "scripts/cursor-hook-adapter.py",
"sha256": "a867c232a9e920e90528794f3a683e47a261823caca454dcc65cd5f4820eb265",
"bytes": 21685
"sha256": "057e22bb2f4df0fc1cd143abb6250876238c1039182e70f097291532ebf53a14",
"bytes": 24459
},
{
"path": "scripts/posttool-bash-hints.py",
"sha256": "4428c102fbf1485eba0362fa7920e23688b64f46b4f8652b3f6230c78668c351",
"bytes": 9217
},
{
"path": "scripts/pretool-crash-route.py",
"sha256": "797990a0857d08e9f7893246e44556058d4fab8ea9f7bce35e0041d4cdc29cfd",
"bytes": 5569
},
{
"path": "scripts/project_detect.py",
"sha256": "8fbd3975b329995d563c2f129c56c49c5056ea6a6f26541131c7c065c0c5be7c",
@@ -1853,8 +1858,8 @@
}
],
"totals": {
"files": 370,
"bytes": 7114548
"files": 371,
"bytes": 7123102
},
"excludedMirrors": 30,
"classes": {
+65
View File
@@ -205,6 +205,69 @@ def _workspace_root(payload: Dict[str, Any]) -> str:
return os.getcwd()
# The canonical crash hints name the bare `xcsym` binary, which Cursor installs has no
# access to — the plugin routes xcsym through MCP. Map the invocation forms, longest
# first, and fail closed if any backticked invocation survives: shipping no hint is
# strictly better than telling the model to run a binary that is not there.
_XCSYM_CRASH_FORMS = (
(re.compile(r'`xcsym crash --format=summary "([^"\n]*)"`'),
r'the `axiom_xcsym_crash` MCP tool with `file: "\1"` and `format: "summary"`'),
(re.compile(r"`xcsym crash --format=summary <path>`"),
'the `axiom_xcsym_crash` MCP tool with `format: "summary"`'),
(re.compile(r"`xcsym crash --format=summary`"),
'the `axiom_xcsym_crash` MCP tool with `format: "summary"`'),
(re.compile(r"`xcsym crash`"), "the `axiom_xcsym_crash` MCP tool"),
)
def _cursor_crash_hint(context: str) -> str:
for pattern, replacement in _XCSYM_CRASH_FORMS:
context = pattern.sub(replacement, context)
if "`xcsym" in context:
raise AdapterError("unmapped xcsym invocation in crash hint")
return context
def pretool_read(payload: Dict[str, Any]) -> Dict[str, str]:
"""Port of the canonical PreToolUse(Read) crash routing to Cursor's preToolUse.
Emits advisory context only. No `permission` field is returned, so the read is
never gated by this plugin.
"""
if payload.get("tool_name") != "Read":
raise AdapterError("unexpected read tool")
tool_input = payload.get("tool_input")
if not isinstance(tool_input, dict):
return {}
file_path = tool_input.get("file_path")
if not isinstance(file_path, str) or not file_path or _has_control_characters(file_path):
return {}
child_output = run_child(
"pretool-crash-route.py",
{"tool_name": "Read", "tool_input": {"file_path": file_path}},
cwd=_workspace_root(payload),
)
if not child_output.strip():
return {}
try:
response = json.loads(child_output)
except json.JSONDecodeError:
raise AdapterError("invalid child JSON")
if not isinstance(response, dict):
raise AdapterError("invalid child JSON")
specific = response.get("hookSpecificOutput")
if not isinstance(specific, dict):
return {}
context = specific.get("additionalContext")
if not isinstance(context, str):
return {}
context = context.strip()
# The hint interpolates the file path, so it carries caller-influenced text.
if not context or len(context) > MAX_ROUTED_CONTEXT_CHARS or _has_control_characters(context):
raise AdapterError("unexpected crash hint")
return {"additional_context": _cursor_crash_hint(context)}
def prompt_submit(payload: Dict[str, Any]) -> Dict[str, str]:
"""Port of the canonical UserPromptSubmit router to Cursor's beforeSubmitPrompt.
@@ -541,6 +604,8 @@ def dispatch(mode: str, payload: Dict[str, Any]) -> Dict[str, str]:
return post_write(payload)
if mode == "prompt-submit":
return prompt_submit(payload)
if mode == "pretool-read":
return pretool_read(payload)
raise AdapterError("unknown mode")
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""PreToolUse hook that routes crash-file Read calls to xcsym.
When an agent is about to Read a crash file (.ips, legacy .crash text,
or an .xccrashpoint bundle), this hook emits additionalContext suggesting
the agent run `xcsym crash --format=summary <path>` first. The Read
still proceeds this is purely an advisory hint, never a block.
The hint is narrow and situational: for a raw .ips or .crash file we
route straight to xcsym, for an .xccrashpoint bundle (a directory, not
a readable file) we point at the nested Logs/*.crash path users
actually want to analyze.
Input on stdin (JSON):
{
"session_id": "...",
"tool_name": "Read",
"tool_input": {"file_path": "/absolute/path"}
}
Output on stdout (JSON) when a match fires:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": "<hint>"
}
}
Any other input shape (non-Read tool, missing file_path, non-crash
path, malformed JSON) empty stdout / exit 0. The hook never fails
a broken hook shouldn't block Read operations.
"""
from __future__ import annotations
import json
import sys
def classify_path(path: str) -> str:
"""Categorize a file path so the caller can pick the right hint.
Returns one of:
"ips" .ips file
"crash_text" legacy .crash file (not inside a bundle)
"xccrashpoint_bundle_root" the .xccrashpoint directory itself
"xccrashpoint_inner_crash" .crash nested inside a .xccrashpoint bundle
"xccrashpoint_inner_other" some other file inside a bundle
"" not a crash path
"""
if not isinstance(path, str) or not path:
return ""
# Bundle root: path ends in .xccrashpoint (no trailing slash) OR
# ends in .xccrashpoint/ (some shells surface that shape).
if path.endswith(".xccrashpoint") or path.endswith(".xccrashpoint/"):
return "xccrashpoint_bundle_root"
# Inside a bundle: distinguish the nested .crash (the one the
# user actually wants xcsym to read) from other files they might
# be Read-ing for metadata (DistributionInfo.json, PointInfo.json).
# The `.xccrashpoint/` token (no leading slash) covers both the
# `/foo.xccrashpoint/inside` and `foo.xccrashpoint/inside` shapes.
if ".xccrashpoint/" in path:
if path.endswith(".crash"):
return "xccrashpoint_inner_crash"
return "xccrashpoint_inner_other"
if path.endswith(".ips"):
return "ips"
if path.endswith(".crash"):
return "crash_text"
return ""
# Hint text is kept tight so additionalContext doesn't bloat the
# agent's context window. Each hint names the exact command the agent
# should run and why — no decision tree, no prose padding.
_HINTS = {
"ips": (
"This path is an .ips crash report. Before reading it as text, "
"run `xcsym crash --format=summary \"{path}\"` — it parses the "
"file, symbolicates against local dSYMs, and tags the crash "
"pattern (swift_forced_unwrap, watchdog_termination, etc.). "
"The JSON output is what you want to analyze; the raw .ips is "
"noisy. See the axiom-tools skill (skills/xcsym-ref.md) for "
"the full workflow."
),
"crash_text": (
"This path is an Apple legacy .crash text file (Xcode Organizer "
"export). Run `xcsym crash --format=summary \"{path}\"` first — "
"xcsym parses the legacy format, symbolicates via dSYM discovery, "
"and categorizes the crash. The text file is hard to skim "
"directly; the JSON output surfaces pattern_tag + crashed frames."
),
"xccrashpoint_inner_crash": (
"This path is a .crash file inside an .xccrashpoint bundle. Pass "
"it directly to xcsym: `xcsym crash --format=summary \"{path}\"`. "
"If there's also a `LocallySymbolicated/` sibling with the same "
"timestamp, prefer that one — it already has dSYM symbols baked in."
),
"xccrashpoint_bundle_root": (
"This path is an .xccrashpoint bundle (a directory, not a file). "
"xcsym needs a .crash text file inside it. The crash lives at "
"`{path}/Filters/*/Logs/*.crash` — list the directory to pick the "
"right variant and pass that path to `xcsym crash`."
),
"xccrashpoint_inner_other": (
"This file is inside an .xccrashpoint bundle but isn't the crash "
"payload. If the goal is crash analysis, the relevant file is at "
"`Filters/*/Logs/*.crash` under the bundle root — route that path "
"to `xcsym crash --format=summary`."
),
}
def build_output(kind: str, path: str) -> dict:
"""Construct the hookSpecificOutput envelope for a matched path."""
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": _HINTS[kind].format(path=path),
}
}
def main() -> int:
try:
data = json.load(sys.stdin)
except Exception:
return 0 # malformed input → silent no-op
if not isinstance(data, dict):
return 0
if data.get("tool_name") != "Read":
return 0
ti = data.get("tool_input") or {}
if not isinstance(ti, dict):
return 0
path = ti.get("file_path")
kind = classify_path(path)
if not kind:
return 0
print(json.dumps(build_output(kind, path)))
return 0
if __name__ == "__main__":
sys.exit(main())
+2 -1
View File
@@ -67,8 +67,9 @@ Cursor does not provide Axiom's canonical per-agent tool allowlists. Every gener
## Hooks
The plugin registers supported Cursor hooks for session start, prompt submission, and post-tool shell/write events. They add routing hints or diagnostics as `additional_context` where available.
The plugin registers supported Cursor hooks for session start, prompt submission, file reads, and post-tool shell/write events. They add routing hints or diagnostics as `additional_context` where available.
- The read hook routes crash reports. Opening an `.ips`, legacy `.crash`, or `.xccrashpoint` path adds a note pointing at the `axiom_xcsym_crash` MCP tool instead of reading the raw file. It emits no `permission` field, so it never gates the read.
- The prompt hook is the per-prompt router. Cursor's `beforeSubmitPrompt` supplies the prompt text and accepts `additional_context`, so Axiom's canonical routing carries over: a prompt that matches a router is annotated with the skill to invoke before the model answers. It stays silent outside an Apple project and on prompts under five characters.
- Hooks are advisory and fail open on malformed input, missing files, child failure, oversized output, or timeout.
+1 -1
View File
@@ -201,7 +201,7 @@ test("hook counts and dispositions remain tied to canonical sources", () => {
} else if (event === "UserPromptSubmit" && entry.matcher === undefined) {
expected = "beforeSubmitPrompt.additional_context";
} else if (event === "PreToolUse" && entry.matcher === "Read") {
expected = "omitted";
expected = "preToolUse.additional_context";
} else if (
event === "PostToolUse" &&
(entry.matcher === "Bash" || entry.matcher === "Write|Edit")
+1 -1
View File
@@ -84,7 +84,7 @@ export const CURSOR_ALLOWED_AGENT_FIELDS = new Set([
export const CURSOR_HOOK_DISPOSITIONS = Object.freeze({
SessionStart: "sessionStart.additional_context",
UserPromptSubmit: "beforeSubmitPrompt.additional_context",
"PreToolUse(Read)": "omitted",
"PreToolUse(Read)": "preToolUse.additional_context",
"PostToolUse(Bash)": "postToolUse.additional_context",
"PostToolUse(Write|Edit)": "postToolUse.additional_context",
SubagentStart: "prompt",
@@ -43,6 +43,24 @@
"/private/tmp/claude-501/-Users-someone-Projects-Example/00000000-1111-2222-3333-444444444444/scratchpad/cursor-hook-probe"
]
},
"pretool-read": {
"conversation_id": "<redacted-conversation_id>",
"cursor_version": "3.17.8",
"generation_id": "<redacted-generation_id>",
"hook_event_name": "preToolUse",
"model": "Cursor Grok 4.6",
"session_id": "<redacted-session_id>",
"tool_input": {
"file_path": "<workspace>/Sample.ips"
},
"tool_name": "Read",
"tool_use_id": "<redacted-tool_use_id>",
"transcript_path": "<redacted-transcript_path>",
"user_email": "<redacted-user_email>",
"workspace_roots": [
"<workspace>"
]
},
"prompt-submit": {
"attachments": [],
"composer_mode": "agent",
+4
View File
@@ -94,6 +94,9 @@ test("renders the native Cursor hook manifest and non-executable runtime copies"
beforeSubmitPrompt: [
{ command: "python3 ./scripts/cursor-hook-adapter.py prompt-submit", timeout: 5 },
],
preToolUse: [
{ command: "python3 ./scripts/cursor-hook-adapter.py pretool-read", matcher: "Read", timeout: 5 },
],
postToolUse: [
{ command: "python3 ./scripts/cursor-hook-adapter.py post-shell", matcher: "Shell", timeout: 5 },
{ command: "python3 ./scripts/cursor-hook-adapter.py post-write", matcher: "Write", timeout: 5 },
@@ -104,6 +107,7 @@ test("renders the native Cursor hook manifest and non-executable runtime copies"
"hooks/hooks.json",
"scripts/cursor-hook-adapter.py",
"scripts/posttool-bash-hints.py",
"scripts/pretool-crash-route.py",
"scripts/project_detect.py",
"scripts/swift-guardrails.py",
"scripts/user-prompt-submit.py",
+4 -1
View File
@@ -23,6 +23,9 @@ const HOOKS_DOCUMENT = {
beforeSubmitPrompt: [
{ command: "python3 ./scripts/cursor-hook-adapter.py prompt-submit", timeout: 5 },
],
preToolUse: [
{ command: "python3 ./scripts/cursor-hook-adapter.py pretool-read", matcher: "Read", timeout: 5 },
],
postToolUse: [
{ command: "python3 ./scripts/cursor-hook-adapter.py post-shell", matcher: "Shell", timeout: 5 },
{ command: "python3 ./scripts/cursor-hook-adapter.py post-write", matcher: "Write", timeout: 5 },
@@ -35,7 +38,7 @@ function readFile(source: string): string {
}
export function renderCursorHooks(): VirtualFile[] {
const runtime = ["project_detect.py", "posttool-bash-hints.py", "swift-guardrails.py", "user-prompt-submit.py"].map((filename) => ({
const runtime = ["pretool-crash-route.py", "project_detect.py", "posttool-bash-hints.py", "swift-guardrails.py", "user-prompt-submit.py"].map((filename) => ({
path: `scripts/${filename}`,
content: readFile(path.join(canonicalHooksDirectory, filename)),
mode: 0o644 as const,
+55
View File
@@ -132,3 +132,58 @@ test("beforeSubmitPrompt ignores an absent or trivial prompt", () => {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test("preToolUse(Read) routes a crash file to the MCP tool, not a bare binary", () => {
const workspace = applePackage();
try {
const crash = path.join(workspace, "Sample.ips");
fs.writeFileSync(crash, '{"app_name":"Demo"}\n');
const payload = {
...FIXTURE["pretool-read"],
workspace_roots: [workspace],
tool_input: { file_path: crash },
};
assert.ok(!("cwd" in payload), "the recorded Cursor payload has no cwd");
const { response, stderr } = invoke("pretool-read", payload);
assert.equal(stderr, "");
const context = (response as { additional_context?: string }).additional_context ?? "";
assert.match(context, /axiom_xcsym_crash/, "the hint must name the MCP tool");
assert.doesNotMatch(context, /`xcsym/, "no bare xcsym invocation may reach Cursor");
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test("preToolUse(Read) stays silent on an ordinary file", () => {
const workspace = applePackage();
try {
const plain = path.join(workspace, "notes.txt");
fs.writeFileSync(plain, "hello\n");
const payload = {
...FIXTURE["pretool-read"],
workspace_roots: [workspace],
tool_input: { file_path: plain },
};
assert.deepEqual(invoke("pretool-read", payload).response, {});
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test("preToolUse(Read) never returns a permission field", () => {
const workspace = applePackage();
try {
const crash = path.join(workspace, "Sample.ips");
fs.writeFileSync(crash, '{"app_name":"Demo"}\n');
const { response } = invoke("pretool-read", {
...FIXTURE["pretool-read"],
workspace_roots: [workspace],
tool_input: { file_path: crash },
});
for (const field of ["permission", "decision", "failClosed"]) {
assert.ok(!(field in (response as object)), `${field} must not be emitted`);
}
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
@@ -205,6 +205,69 @@ def _workspace_root(payload: Dict[str, Any]) -> str:
return os.getcwd()
# The canonical crash hints name the bare `xcsym` binary, which Cursor installs has no
# access to — the plugin routes xcsym through MCP. Map the invocation forms, longest
# first, and fail closed if any backticked invocation survives: shipping no hint is
# strictly better than telling the model to run a binary that is not there.
_XCSYM_CRASH_FORMS = (
(re.compile(r'`xcsym crash --format=summary "([^"\n]*)"`'),
r'the `axiom_xcsym_crash` MCP tool with `file: "\1"` and `format: "summary"`'),
(re.compile(r"`xcsym crash --format=summary <path>`"),
'the `axiom_xcsym_crash` MCP tool with `format: "summary"`'),
(re.compile(r"`xcsym crash --format=summary`"),
'the `axiom_xcsym_crash` MCP tool with `format: "summary"`'),
(re.compile(r"`xcsym crash`"), "the `axiom_xcsym_crash` MCP tool"),
)
def _cursor_crash_hint(context: str) -> str:
for pattern, replacement in _XCSYM_CRASH_FORMS:
context = pattern.sub(replacement, context)
if "`xcsym" in context:
raise AdapterError("unmapped xcsym invocation in crash hint")
return context
def pretool_read(payload: Dict[str, Any]) -> Dict[str, str]:
"""Port of the canonical PreToolUse(Read) crash routing to Cursor's preToolUse.
Emits advisory context only. No `permission` field is returned, so the read is
never gated by this plugin.
"""
if payload.get("tool_name") != "Read":
raise AdapterError("unexpected read tool")
tool_input = payload.get("tool_input")
if not isinstance(tool_input, dict):
return {}
file_path = tool_input.get("file_path")
if not isinstance(file_path, str) or not file_path or _has_control_characters(file_path):
return {}
child_output = run_child(
"pretool-crash-route.py",
{"tool_name": "Read", "tool_input": {"file_path": file_path}},
cwd=_workspace_root(payload),
)
if not child_output.strip():
return {}
try:
response = json.loads(child_output)
except json.JSONDecodeError:
raise AdapterError("invalid child JSON")
if not isinstance(response, dict):
raise AdapterError("invalid child JSON")
specific = response.get("hookSpecificOutput")
if not isinstance(specific, dict):
return {}
context = specific.get("additionalContext")
if not isinstance(context, str):
return {}
context = context.strip()
# The hint interpolates the file path, so it carries caller-influenced text.
if not context or len(context) > MAX_ROUTED_CONTEXT_CHARS or _has_control_characters(context):
raise AdapterError("unexpected crash hint")
return {"additional_context": _cursor_crash_hint(context)}
def prompt_submit(payload: Dict[str, Any]) -> Dict[str, str]:
"""Port of the canonical UserPromptSubmit router to Cursor's beforeSubmitPrompt.
@@ -541,6 +604,8 @@ def dispatch(mode: str, payload: Dict[str, Any]) -> Dict[str, str]:
return post_write(payload)
if mode == "prompt-submit":
return prompt_submit(payload)
if mode == "pretool-read":
return pretool_read(payload)
raise AdapterError("unknown mode")
+1
View File
@@ -30,6 +30,7 @@ export const CURSOR_ALLOWED_COMMAND_FIELDS = new Set([
const RESOURCE_DIRECTORIES = new Set(["skills", "references", "scripts", "assets"]);
const RUNTIME_FILES = [
"hooks/posttool-bash-hints.py",
"hooks/pretool-crash-route.py",
"hooks/project_detect.py",
"hooks/swift-guardrails.py",
"hooks/user-prompt-submit.py",