2026-08-20 19:28:19 -07:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Bounded, fail-open bridge from Cursor hooks to Axiom's canonical hooks."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import selectors
|
|
|
|
|
import signal
|
|
|
|
|
import stat
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
import tempfile
|
|
|
|
|
import time
|
|
|
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
|
|
feat(cursor): port per-prompt router injection to beforeSubmitPrompt
The capability ledger recorded UserPromptSubmit as "omitted" with no stated
rationale, which dropped Axiom's core routing-enforcement mechanism and left
skill activation in Cursor resting entirely on description-based discovery.
Cursor supports the capability.
Its beforeSubmitPrompt event carries the prompt text and its response accepts
additional_context — the same mechanism user-prompt-submit.py already emits
through hookSpecificOutput.additionalContext. Verified against a live Cursor
3.17.8 session, not inferred: an injected marker token came back at the head of
the model's reply, and with this adapter mode in place the prompt "My SwiftUI
list scrolls badly on device" is routed to axiom-swiftui, which the model then
opens before answering.
The adapter gains a prompt-submit mode that translates the payload, runs the
canonical router as a child, and returns its guidance as additional_context.
The router child now ships with the distribution.
Two details worth noting:
_workspace_root centralises the fact that Cursor omits cwd on several events
but always sends workspace_roots. Children run their own project gate against
the directory they start in, so the router child is now started in the
workspace rather than the plugin directory. session_start uses the same helper.
Child output is not forwarded blindly. The router emits a fixed template naming
skills from its own table and never prompt text, so the adapter requires that
shape, bounds the length, and rejects control characters before the string
reaches the model's turn.
Also stops the hooks writing __pycache__ into the plugin directory Cursor
manages, via sys.dont_write_bytecode and -B on the child spawn.
2026-08-23 12:38:09 -07:00
|
|
|
# Importing the sibling detector would otherwise write __pycache__ into the plugin
|
|
|
|
|
# directory Cursor manages. A hook must not litter the user's install.
|
|
|
|
|
sys.dont_write_bytecode = True
|
|
|
|
|
|
2026-08-20 19:28:19 -07:00
|
|
|
|
|
|
|
|
MAX_STDIN_BYTES = 1024 * 1024
|
|
|
|
|
MAX_CHILD_OUTPUT_BYTES = 64 * 1024
|
|
|
|
|
# Keep the post-write boundary deliberately small. Files above 1 MiB fail open
|
|
|
|
|
# without a scan.
|
|
|
|
|
MAX_POST_WRITE_FILE_BYTES = 1024 * 1024
|
feat(cursor): port per-prompt router injection to beforeSubmitPrompt
The capability ledger recorded UserPromptSubmit as "omitted" with no stated
rationale, which dropped Axiom's core routing-enforcement mechanism and left
skill activation in Cursor resting entirely on description-based discovery.
Cursor supports the capability.
Its beforeSubmitPrompt event carries the prompt text and its response accepts
additional_context — the same mechanism user-prompt-submit.py already emits
through hookSpecificOutput.additionalContext. Verified against a live Cursor
3.17.8 session, not inferred: an injected marker token came back at the head of
the model's reply, and with this adapter mode in place the prompt "My SwiftUI
list scrolls badly on device" is routed to axiom-swiftui, which the model then
opens before answering.
The adapter gains a prompt-submit mode that translates the payload, runs the
canonical router as a child, and returns its guidance as additional_context.
The router child now ships with the distribution.
Two details worth noting:
_workspace_root centralises the fact that Cursor omits cwd on several events
but always sends workspace_roots. Children run their own project gate against
the directory they start in, so the router child is now started in the
workspace rather than the plugin directory. session_start uses the same helper.
Child output is not forwarded blindly. The router emits a fixed template naming
skills from its own table and never prompt text, so the adapter requires that
shape, bounds the length, and rejects control characters before the string
reaches the model's turn.
Also stops the hooks writing __pycache__ into the plugin directory Cursor
manages, via sys.dont_write_bytecode and -B on the child spawn.
2026-08-23 12:38:09 -07:00
|
|
|
# The canonical router ignores prompts under 5 chars and caps its scan at 2000.
|
|
|
|
|
MIN_ROUTED_PROMPT_CHARS = 5
|
|
|
|
|
MAX_ROUTED_PROMPT_CHARS = 2000
|
|
|
|
|
MAX_ROUTED_CONTEXT_CHARS = 2048
|
feat(cursor): port subagent skill awareness to subagentStart
The third and last capability the ledger recorded as downgraded, replaced by a
static preamble in each agent file. Cursor's subagentStart response accepts
additional_context and its query carries subagent_type, which is the only field
the canonical hook reads, so the port is a field rename.
This is additive, not a replacement. The generated preamble carries an agent's
own declared skills; the hook carries the general skill-awareness text gated by
agent type. Both now ship.
Verified as far as a free Cursor plan allows: the hook registers, fires, and
delivers a payload carrying subagent_type, task, and subagent_model — confirmed
against a live Cursor 3.17.8 delegation, which fires subagentStart three times
before refusing to start the subagent with "Named models unavailable. Free
plans can only use Auto." What a free plan cannot show is whether the returned
context reaches a subagent that never starts. The docs and the submission
matrix say so rather than implying full verification. If delivery does not
work, the preamble still carries the declared skills, so the failure mode is
the status quo rather than a regression.
The context guard needed widening: skill awareness is legitimately
multi-paragraph, where the router and crash hints are single-line. Newline and
tab are now permitted, every other control character is still rejected, and the
bound is separate at 8 KiB.
2026-08-23 13:18:08 -07:00
|
|
|
# Skill-awareness guidance is legitimately multi-paragraph.
|
|
|
|
|
MAX_SUBAGENT_CONTEXT_CHARS = 8192
|
2026-08-20 19:28:19 -07:00
|
|
|
# Cursor gives this hook 5 seconds. Leave 1.25 seconds for process-group teardown
|
|
|
|
|
# and JSON emission if the canonical child reaches its internal deadline.
|
|
|
|
|
CHILD_TIMEOUT_SECONDS = 3.75
|
|
|
|
|
SCRIPT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
|
|
|
|
|
_SWIFT_FINDINGS: Tuple[Tuple[str, str, str], ...] = (
|
|
|
|
|
(
|
|
|
|
|
"@State without access control (use @State private var):",
|
|
|
|
|
"AXIOM_SWIFT_STATE_ACCESS",
|
|
|
|
|
"Add an explicit access level to this @State property (usually @State private var).",
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"to-many @Relationship without a default (add = []):",
|
|
|
|
|
"AXIOM_SWIFTDATA_RELATIONSHIP_DEFAULT",
|
|
|
|
|
"Add a default (= []) to this to-many @Relationship.",
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AdapterError(Exception):
|
|
|
|
|
"""A safely-reportable input or child-process failure."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def diagnostic(code: str) -> None:
|
|
|
|
|
print("[axiom-cursor-hook] {}".format(code), file=sys.stderr)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read_payload() -> Dict[str, Any]:
|
|
|
|
|
raw = sys.stdin.buffer.read(MAX_STDIN_BYTES + 1)
|
|
|
|
|
if len(raw) > MAX_STDIN_BYTES:
|
|
|
|
|
raise AdapterError("input too large")
|
|
|
|
|
try:
|
|
|
|
|
payload = json.loads(raw.decode("utf-8"))
|
|
|
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
|
|
|
raise AdapterError("malformed input")
|
|
|
|
|
if not isinstance(payload, dict):
|
|
|
|
|
raise AdapterError("malformed input")
|
|
|
|
|
return payload
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def signal_child(process: subprocess.Popen, child_signal: int) -> None:
|
|
|
|
|
try:
|
|
|
|
|
if process.pid != os.getpgrp():
|
|
|
|
|
os.killpg(process.pid, child_signal)
|
|
|
|
|
return
|
|
|
|
|
except (OSError, ProcessLookupError):
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
process.send_signal(child_signal)
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def close_pipe(selector: selectors.BaseSelector, pipe: Any) -> None:
|
|
|
|
|
try:
|
|
|
|
|
selector.unregister(pipe)
|
|
|
|
|
except (KeyError, ValueError):
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
pipe.close()
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def stop_and_drain(process: subprocess.Popen, selector: selectors.BaseSelector) -> None:
|
|
|
|
|
signal_child(process, signal.SIGTERM)
|
|
|
|
|
drain_deadline = time.monotonic() + 0.25
|
|
|
|
|
while selector.get_map() and time.monotonic() < drain_deadline:
|
|
|
|
|
for key, _ in selector.select(0.05):
|
|
|
|
|
try:
|
|
|
|
|
chunk = os.read(key.fd, 8192)
|
|
|
|
|
except OSError:
|
|
|
|
|
chunk = b""
|
|
|
|
|
if not chunk:
|
|
|
|
|
close_pipe(selector, key.fileobj)
|
|
|
|
|
signal_child(process, signal.SIGKILL)
|
|
|
|
|
for key in list(selector.get_map().values()):
|
|
|
|
|
close_pipe(selector, key.fileobj)
|
|
|
|
|
try:
|
|
|
|
|
process.wait(timeout=0.25)
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
signal_child(process, signal.SIGKILL)
|
|
|
|
|
try:
|
|
|
|
|
process.wait(timeout=0.25)
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
feat(cursor): port per-prompt router injection to beforeSubmitPrompt
The capability ledger recorded UserPromptSubmit as "omitted" with no stated
rationale, which dropped Axiom's core routing-enforcement mechanism and left
skill activation in Cursor resting entirely on description-based discovery.
Cursor supports the capability.
Its beforeSubmitPrompt event carries the prompt text and its response accepts
additional_context — the same mechanism user-prompt-submit.py already emits
through hookSpecificOutput.additionalContext. Verified against a live Cursor
3.17.8 session, not inferred: an injected marker token came back at the head of
the model's reply, and with this adapter mode in place the prompt "My SwiftUI
list scrolls badly on device" is routed to axiom-swiftui, which the model then
opens before answering.
The adapter gains a prompt-submit mode that translates the payload, runs the
canonical router as a child, and returns its guidance as additional_context.
The router child now ships with the distribution.
Two details worth noting:
_workspace_root centralises the fact that Cursor omits cwd on several events
but always sends workspace_roots. Children run their own project gate against
the directory they start in, so the router child is now started in the
workspace rather than the plugin directory. session_start uses the same helper.
Child output is not forwarded blindly. The router emits a fixed template naming
skills from its own table and never prompt text, so the adapter requires that
shape, bounds the length, and rejects control characters before the string
reaches the model's turn.
Also stops the hooks writing __pycache__ into the plugin directory Cursor
manages, via sys.dont_write_bytecode and -B on the child spawn.
2026-08-23 12:38:09 -07:00
|
|
|
def run_child(filename: str, payload: Dict[str, Any], env: Optional[Dict[str, str]] = None, cwd: Optional[str] = None) -> str:
|
2026-08-20 19:28:19 -07:00
|
|
|
child = os.path.join(SCRIPT_DIRECTORY, filename)
|
|
|
|
|
if not os.path.isfile(child):
|
|
|
|
|
raise AdapterError("missing child")
|
|
|
|
|
child_input = json.dumps(payload).encode("utf-8")
|
|
|
|
|
selector = selectors.DefaultSelector()
|
|
|
|
|
process: Optional[subprocess.Popen] = None
|
|
|
|
|
buffers = {"stdout": bytearray(), "stderr": bytearray()}
|
|
|
|
|
failure: Optional[str] = None
|
|
|
|
|
try:
|
|
|
|
|
with tempfile.TemporaryFile() as stdin_file:
|
|
|
|
|
stdin_file.write(child_input)
|
|
|
|
|
stdin_file.seek(0)
|
|
|
|
|
process = subprocess.Popen(
|
feat(cursor): port per-prompt router injection to beforeSubmitPrompt
The capability ledger recorded UserPromptSubmit as "omitted" with no stated
rationale, which dropped Axiom's core routing-enforcement mechanism and left
skill activation in Cursor resting entirely on description-based discovery.
Cursor supports the capability.
Its beforeSubmitPrompt event carries the prompt text and its response accepts
additional_context — the same mechanism user-prompt-submit.py already emits
through hookSpecificOutput.additionalContext. Verified against a live Cursor
3.17.8 session, not inferred: an injected marker token came back at the head of
the model's reply, and with this adapter mode in place the prompt "My SwiftUI
list scrolls badly on device" is routed to axiom-swiftui, which the model then
opens before answering.
The adapter gains a prompt-submit mode that translates the payload, runs the
canonical router as a child, and returns its guidance as additional_context.
The router child now ships with the distribution.
Two details worth noting:
_workspace_root centralises the fact that Cursor omits cwd on several events
but always sends workspace_roots. Children run their own project gate against
the directory they start in, so the router child is now started in the
workspace rather than the plugin directory. session_start uses the same helper.
Child output is not forwarded blindly. The router emits a fixed template naming
skills from its own table and never prompt text, so the adapter requires that
shape, bounds the length, and rejects control characters before the string
reaches the model's turn.
Also stops the hooks writing __pycache__ into the plugin directory Cursor
manages, via sys.dont_write_bytecode and -B on the child spawn.
2026-08-23 12:38:09 -07:00
|
|
|
[sys.executable, "-B", child], # -B: no __pycache__ in the plugin directory
|
2026-08-20 19:28:19 -07:00
|
|
|
stdin=stdin_file,
|
|
|
|
|
stdout=subprocess.PIPE,
|
|
|
|
|
stderr=subprocess.PIPE,
|
|
|
|
|
env=env,
|
feat(cursor): port per-prompt router injection to beforeSubmitPrompt
The capability ledger recorded UserPromptSubmit as "omitted" with no stated
rationale, which dropped Axiom's core routing-enforcement mechanism and left
skill activation in Cursor resting entirely on description-based discovery.
Cursor supports the capability.
Its beforeSubmitPrompt event carries the prompt text and its response accepts
additional_context — the same mechanism user-prompt-submit.py already emits
through hookSpecificOutput.additionalContext. Verified against a live Cursor
3.17.8 session, not inferred: an injected marker token came back at the head of
the model's reply, and with this adapter mode in place the prompt "My SwiftUI
list scrolls badly on device" is routed to axiom-swiftui, which the model then
opens before answering.
The adapter gains a prompt-submit mode that translates the payload, runs the
canonical router as a child, and returns its guidance as additional_context.
The router child now ships with the distribution.
Two details worth noting:
_workspace_root centralises the fact that Cursor omits cwd on several events
but always sends workspace_roots. Children run their own project gate against
the directory they start in, so the router child is now started in the
workspace rather than the plugin directory. session_start uses the same helper.
Child output is not forwarded blindly. The router emits a fixed template naming
skills from its own table and never prompt text, so the adapter requires that
shape, bounds the length, and rejects control characters before the string
reaches the model's turn.
Also stops the hooks writing __pycache__ into the plugin directory Cursor
manages, via sys.dont_write_bytecode and -B on the child spawn.
2026-08-23 12:38:09 -07:00
|
|
|
cwd=cwd,
|
2026-08-20 19:28:19 -07:00
|
|
|
start_new_session=True,
|
|
|
|
|
)
|
|
|
|
|
assert process.stdout is not None
|
|
|
|
|
assert process.stderr is not None
|
|
|
|
|
selector.register(process.stdout, selectors.EVENT_READ, "stdout")
|
|
|
|
|
selector.register(process.stderr, selectors.EVENT_READ, "stderr")
|
|
|
|
|
deadline = time.monotonic() + CHILD_TIMEOUT_SECONDS
|
|
|
|
|
while selector.get_map():
|
|
|
|
|
remaining = deadline - time.monotonic()
|
|
|
|
|
if remaining <= 0:
|
|
|
|
|
failure = "child timeout"
|
|
|
|
|
break
|
|
|
|
|
events = selector.select(remaining)
|
|
|
|
|
if not events:
|
|
|
|
|
failure = "child timeout"
|
|
|
|
|
break
|
|
|
|
|
for key, _ in events:
|
|
|
|
|
stream = key.data
|
|
|
|
|
buffer = buffers[stream]
|
|
|
|
|
read_size = min(8192, MAX_CHILD_OUTPUT_BYTES + 1 - len(buffer))
|
|
|
|
|
try:
|
|
|
|
|
chunk = os.read(key.fd, read_size)
|
|
|
|
|
except OSError:
|
|
|
|
|
chunk = b""
|
|
|
|
|
if not chunk:
|
|
|
|
|
close_pipe(selector, key.fileobj)
|
|
|
|
|
continue
|
|
|
|
|
buffer.extend(chunk)
|
|
|
|
|
if len(buffer) > MAX_CHILD_OUTPUT_BYTES:
|
|
|
|
|
failure = "child output too large"
|
|
|
|
|
break
|
|
|
|
|
if failure is not None:
|
|
|
|
|
break
|
|
|
|
|
if failure is None:
|
|
|
|
|
try:
|
|
|
|
|
process.wait(timeout=max(0, deadline - time.monotonic()))
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
failure = "child timeout"
|
|
|
|
|
except OSError:
|
|
|
|
|
failure = "child launch failure"
|
|
|
|
|
finally:
|
|
|
|
|
if process is not None and (failure is not None or selector.get_map()):
|
|
|
|
|
stop_and_drain(process, selector)
|
|
|
|
|
selector.close()
|
|
|
|
|
if failure is not None:
|
|
|
|
|
raise AdapterError(failure)
|
|
|
|
|
assert process is not None
|
|
|
|
|
if process.returncode != 0:
|
|
|
|
|
raise AdapterError("child failure")
|
|
|
|
|
return bytes(buffers["stdout"]).decode("utf-8", errors="replace")
|
|
|
|
|
|
|
|
|
|
|
feat(cursor): port per-prompt router injection to beforeSubmitPrompt
The capability ledger recorded UserPromptSubmit as "omitted" with no stated
rationale, which dropped Axiom's core routing-enforcement mechanism and left
skill activation in Cursor resting entirely on description-based discovery.
Cursor supports the capability.
Its beforeSubmitPrompt event carries the prompt text and its response accepts
additional_context — the same mechanism user-prompt-submit.py already emits
through hookSpecificOutput.additionalContext. Verified against a live Cursor
3.17.8 session, not inferred: an injected marker token came back at the head of
the model's reply, and with this adapter mode in place the prompt "My SwiftUI
list scrolls badly on device" is routed to axiom-swiftui, which the model then
opens before answering.
The adapter gains a prompt-submit mode that translates the payload, runs the
canonical router as a child, and returns its guidance as additional_context.
The router child now ships with the distribution.
Two details worth noting:
_workspace_root centralises the fact that Cursor omits cwd on several events
but always sends workspace_roots. Children run their own project gate against
the directory they start in, so the router child is now started in the
workspace rather than the plugin directory. session_start uses the same helper.
Child output is not forwarded blindly. The router emits a fixed template naming
skills from its own table and never prompt text, so the adapter requires that
shape, bounds the length, and rejects control characters before the string
reaches the model's turn.
Also stops the hooks writing __pycache__ into the plugin directory Cursor
manages, via sys.dont_write_bytecode and -B on the child spawn.
2026-08-23 12:38:09 -07:00
|
|
|
def _workspace_root(payload: Dict[str, Any]) -> str:
|
|
|
|
|
"""Best available workspace directory for a Cursor hook payload.
|
|
|
|
|
|
|
|
|
|
Cursor sends `cwd` on some events and `workspace_roots` on all of them, so prefer
|
|
|
|
|
the explicit value, fall back to the first root, and only then to the adapter's own
|
|
|
|
|
directory. Children run their own project gate against the directory they start in.
|
|
|
|
|
"""
|
|
|
|
|
cwd = payload.get("cwd")
|
|
|
|
|
if isinstance(cwd, str) and cwd and os.path.isdir(cwd):
|
|
|
|
|
return cwd
|
|
|
|
|
roots = payload.get("workspace_roots")
|
|
|
|
|
if isinstance(roots, list) and roots and isinstance(roots[0], str) and os.path.isdir(roots[0]):
|
|
|
|
|
return roots[0]
|
|
|
|
|
return os.getcwd()
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 13:09:08 -07:00
|
|
|
# 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)}
|
|
|
|
|
|
|
|
|
|
|
feat(cursor): port subagent skill awareness to subagentStart
The third and last capability the ledger recorded as downgraded, replaced by a
static preamble in each agent file. Cursor's subagentStart response accepts
additional_context and its query carries subagent_type, which is the only field
the canonical hook reads, so the port is a field rename.
This is additive, not a replacement. The generated preamble carries an agent's
own declared skills; the hook carries the general skill-awareness text gated by
agent type. Both now ship.
Verified as far as a free Cursor plan allows: the hook registers, fires, and
delivers a payload carrying subagent_type, task, and subagent_model — confirmed
against a live Cursor 3.17.8 delegation, which fires subagentStart three times
before refusing to start the subagent with "Named models unavailable. Free
plans can only use Auto." What a free plan cannot show is whether the returned
context reaches a subagent that never starts. The docs and the submission
matrix say so rather than implying full verification. If delivery does not
work, the preamble still carries the declared skills, so the failure mode is
the status quo rather than a regression.
The context guard needed widening: skill awareness is legitimately
multi-paragraph, where the router and crash hints are single-line. Newline and
tab are now permitted, every other control character is still rejected, and the
bound is separate at 8 KiB.
2026-08-23 13:18:08 -07:00
|
|
|
def subagent_start(payload: Dict[str, Any]) -> Dict[str, str]:
|
|
|
|
|
"""Port of the canonical SubagentStart skill awareness to Cursor's subagentStart.
|
|
|
|
|
|
|
|
|
|
Cursor names the field `subagent_type`; the canonical hook reads `agent_type`.
|
|
|
|
|
This is additive to the per-agent Required Skills preamble in the generated agent
|
|
|
|
|
files: the preamble carries that agent's declared skills, this carries the general
|
|
|
|
|
skill-usage awareness the canonical hook injects, gated by agent type.
|
|
|
|
|
"""
|
|
|
|
|
subagent_type = payload.get("subagent_type")
|
|
|
|
|
if not isinstance(subagent_type, str) or not subagent_type:
|
|
|
|
|
return {}
|
|
|
|
|
if _has_control_characters(subagent_type):
|
|
|
|
|
return {}
|
|
|
|
|
child_output = run_child(
|
|
|
|
|
"subagent-start.py",
|
|
|
|
|
{"agent_type": subagent_type},
|
|
|
|
|
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()
|
|
|
|
|
if not context:
|
|
|
|
|
return {}
|
|
|
|
|
if len(context) > MAX_SUBAGENT_CONTEXT_CHARS or _has_unsafe_control_characters(context):
|
|
|
|
|
raise AdapterError("unexpected subagent context")
|
|
|
|
|
return {"additional_context": context}
|
|
|
|
|
|
|
|
|
|
|
feat(cursor): port per-prompt router injection to beforeSubmitPrompt
The capability ledger recorded UserPromptSubmit as "omitted" with no stated
rationale, which dropped Axiom's core routing-enforcement mechanism and left
skill activation in Cursor resting entirely on description-based discovery.
Cursor supports the capability.
Its beforeSubmitPrompt event carries the prompt text and its response accepts
additional_context — the same mechanism user-prompt-submit.py already emits
through hookSpecificOutput.additionalContext. Verified against a live Cursor
3.17.8 session, not inferred: an injected marker token came back at the head of
the model's reply, and with this adapter mode in place the prompt "My SwiftUI
list scrolls badly on device" is routed to axiom-swiftui, which the model then
opens before answering.
The adapter gains a prompt-submit mode that translates the payload, runs the
canonical router as a child, and returns its guidance as additional_context.
The router child now ships with the distribution.
Two details worth noting:
_workspace_root centralises the fact that Cursor omits cwd on several events
but always sends workspace_roots. Children run their own project gate against
the directory they start in, so the router child is now started in the
workspace rather than the plugin directory. session_start uses the same helper.
Child output is not forwarded blindly. The router emits a fixed template naming
skills from its own table and never prompt text, so the adapter requires that
shape, bounds the length, and rejects control characters before the string
reaches the model's turn.
Also stops the hooks writing __pycache__ into the plugin directory Cursor
manages, via sys.dont_write_bytecode and -B on the child spawn.
2026-08-23 12:38:09 -07:00
|
|
|
def prompt_submit(payload: Dict[str, Any]) -> Dict[str, str]:
|
|
|
|
|
"""Port of the canonical UserPromptSubmit router to Cursor's beforeSubmitPrompt.
|
|
|
|
|
|
|
|
|
|
Cursor supplies the prompt text and accepts `additional_context` in the response,
|
|
|
|
|
so per-prompt router injection carries over intact rather than being dropped.
|
|
|
|
|
"""
|
|
|
|
|
prompt = payload.get("prompt")
|
|
|
|
|
if not isinstance(prompt, str) or len(prompt) < MIN_ROUTED_PROMPT_CHARS:
|
|
|
|
|
return {}
|
|
|
|
|
if len(prompt) > MAX_ROUTED_PROMPT_CHARS:
|
|
|
|
|
prompt = prompt[:MAX_ROUTED_PROMPT_CHARS]
|
|
|
|
|
workspace = _workspace_root(payload)
|
|
|
|
|
child_output = run_child("user-prompt-submit.py", {"prompt": prompt}, cwd=workspace)
|
|
|
|
|
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 router emits a fixed template naming skills from its own table, never prompt
|
|
|
|
|
# text, so bound the length and reject anything that is not that shape rather than
|
|
|
|
|
# forwarding arbitrary child output into the model's turn.
|
|
|
|
|
if not context.startswith("Axiom:") or len(context) > MAX_ROUTED_CONTEXT_CHARS:
|
|
|
|
|
raise AdapterError("unexpected router context")
|
|
|
|
|
if _has_control_characters(context):
|
|
|
|
|
raise AdapterError("unexpected router context")
|
|
|
|
|
return {"additional_context": context}
|
|
|
|
|
|
|
|
|
|
|
2026-08-20 19:28:19 -07:00
|
|
|
def session_start(payload: Dict[str, Any]) -> Dict[str, str]:
|
|
|
|
|
try:
|
|
|
|
|
from project_detect import resolve_context_decision
|
|
|
|
|
except Exception as error:
|
|
|
|
|
raise AdapterError("missing project detector") from error
|
feat(cursor): port per-prompt router injection to beforeSubmitPrompt
The capability ledger recorded UserPromptSubmit as "omitted" with no stated
rationale, which dropped Axiom's core routing-enforcement mechanism and left
skill activation in Cursor resting entirely on description-based discovery.
Cursor supports the capability.
Its beforeSubmitPrompt event carries the prompt text and its response accepts
additional_context — the same mechanism user-prompt-submit.py already emits
through hookSpecificOutput.additionalContext. Verified against a live Cursor
3.17.8 session, not inferred: an injected marker token came back at the head of
the model's reply, and with this adapter mode in place the prompt "My SwiftUI
list scrolls badly on device" is routed to axiom-swiftui, which the model then
opens before answering.
The adapter gains a prompt-submit mode that translates the payload, runs the
canonical router as a child, and returns its guidance as additional_context.
The router child now ships with the distribution.
Two details worth noting:
_workspace_root centralises the fact that Cursor omits cwd on several events
but always sends workspace_roots. Children run their own project gate against
the directory they start in, so the router child is now started in the
workspace rather than the plugin directory. session_start uses the same helper.
Child output is not forwarded blindly. The router emits a fixed template naming
skills from its own table and never prompt text, so the adapter requires that
shape, bounds the length, and rejects control characters before the string
reaches the model's turn.
Also stops the hooks writing __pycache__ into the plugin directory Cursor
manages, via sys.dont_write_bytecode and -B on the child spawn.
2026-08-23 12:38:09 -07:00
|
|
|
cwd = _workspace_root(payload)
|
2026-08-20 19:28:19 -07:00
|
|
|
if not resolve_context_decision(cwd, os.environ.get("AXIOM_SESSION_CONTEXT")):
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
skill_path = os.path.join(SCRIPT_DIRECTORY, "..", "skills", "axiom-tools", "SKILL.md")
|
|
|
|
|
try:
|
|
|
|
|
with open(skill_path, encoding="utf-8") as skill_file:
|
|
|
|
|
skill = skill_file.read()
|
|
|
|
|
except OSError:
|
|
|
|
|
raise AdapterError("missing session skill")
|
|
|
|
|
|
|
|
|
|
title = next((line[2:].strip() for line in skill.splitlines() if line.startswith("# ")), "Axiom Tools")
|
|
|
|
|
context = (
|
|
|
|
|
"Axiom Cursor session context v1 — {}. For Apple/Swift work, check the matching "
|
|
|
|
|
"axiom-* router before responding. Route environment and build questions first; then "
|
|
|
|
|
"use architecture routers such as axiom-swiftui, axiom-data, and axiom-concurrency."
|
|
|
|
|
).format(title)
|
|
|
|
|
return {"additional_context": context}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def post_shell(payload: Dict[str, Any]) -> Dict[str, str]:
|
|
|
|
|
if payload.get("tool_name") != "Shell":
|
|
|
|
|
raise AdapterError("unexpected shell tool")
|
|
|
|
|
output = payload.get("tool_output")
|
|
|
|
|
if not isinstance(output, str):
|
|
|
|
|
raise AdapterError("malformed shell output")
|
|
|
|
|
try:
|
|
|
|
|
decoded_output = json.loads(output)
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
raise AdapterError("malformed shell output")
|
|
|
|
|
if not isinstance(decoded_output, dict):
|
|
|
|
|
raise AdapterError("malformed shell output")
|
|
|
|
|
|
|
|
|
|
tool_input = payload.get("tool_input")
|
|
|
|
|
if not isinstance(tool_input, dict):
|
|
|
|
|
raise AdapterError("malformed shell input")
|
|
|
|
|
command = tool_input.get("command")
|
|
|
|
|
if not isinstance(command, str):
|
|
|
|
|
raise AdapterError("malformed shell input")
|
|
|
|
|
output_text = "\n".join(
|
|
|
|
|
value for value in (decoded_output.get("stdout"), decoded_output.get("stderr"), decoded_output.get("output"))
|
|
|
|
|
if isinstance(value, str) and value
|
|
|
|
|
)
|
|
|
|
|
duration = payload.get("duration")
|
|
|
|
|
canonical_payload: Dict[str, Any] = {
|
|
|
|
|
"tool_name": "Bash",
|
|
|
|
|
"tool_input": {"command": command},
|
|
|
|
|
}
|
fix(cursor): repair hooks against the live Cursor payload contract
Verified against payloads captured from a live Cursor 3.17.8 agent turn. Every
existing adapter test built its own payload, so they all agreed with the
adapter's assumptions rather than with Cursor.
postToolUse(Write) carries no cwd. _validated_post_write_context required it,
raised "unsafe write cwd", and failed open, so the Swift guardrails never ran
in Cursor at all. cwd now falls back to workspace_roots[0], which every
payload does carry; all existing path validation still applies to it.
duration arrives as a float (11554.364 ms). The int-only check dropped it, so
duration_ms never reached the hints child and no duration hint could fire.
Both int and float are accepted and coerced.
Three assumptions were checked and are correct, and are now pinned so they
cannot rot: tool_output is a JSON-encoded string, the Shell variant is keyed
exitCode/output, and workspace_roots is always present.
real-payload.test.ts runs against the generated distribution rather than the
source tree, because the guardrails child is only co-located with the adapter
in the shipped output. The fixture records the captured shape with identity
fields redacted.
Also documents two support boundaries found while verifying: Cursor Free
refuses subagent delegation, so the plugin's agents register but cannot run
there, and plugin hooks load behind Cursor's enable_cc_plugin_import gate,
which clears them silently when off. Both reach the install guide and the
submission runbook, whose smoke matrix could not otherwise be completed on a
free account.
2026-08-23 12:04:14 -07:00
|
|
|
# Cursor sends fractional milliseconds (e.g. 11554.364), so an int-only check
|
|
|
|
|
# silently drops every duration and no duration hint can ever fire.
|
|
|
|
|
if isinstance(duration, (int, float)) and not isinstance(duration, bool) and duration > 0:
|
|
|
|
|
canonical_payload["duration_ms"] = int(duration)
|
2026-08-20 19:28:19 -07:00
|
|
|
environment = dict(os.environ)
|
|
|
|
|
environment["CURSOR_TOOL_OUTPUT"] = output_text
|
|
|
|
|
child_output = run_child("posttool-bash-hints.py", canonical_payload, environment)
|
|
|
|
|
lines = [line.strip() for line in child_output.splitlines() if line.strip()]
|
|
|
|
|
return {"additional_context": "\n".join(lines)} if lines else {}
|
|
|
|
|
|
|
|
|
|
|
feat(cursor): port subagent skill awareness to subagentStart
The third and last capability the ledger recorded as downgraded, replaced by a
static preamble in each agent file. Cursor's subagentStart response accepts
additional_context and its query carries subagent_type, which is the only field
the canonical hook reads, so the port is a field rename.
This is additive, not a replacement. The generated preamble carries an agent's
own declared skills; the hook carries the general skill-awareness text gated by
agent type. Both now ship.
Verified as far as a free Cursor plan allows: the hook registers, fires, and
delivers a payload carrying subagent_type, task, and subagent_model — confirmed
against a live Cursor 3.17.8 delegation, which fires subagentStart three times
before refusing to start the subagent with "Named models unavailable. Free
plans can only use Auto." What a free plan cannot show is whether the returned
context reaches a subagent that never starts. The docs and the submission
matrix say so rather than implying full verification. If delivery does not
work, the preamble still carries the declared skills, so the failure mode is
the status quo rather than a regression.
The context guard needed widening: skill awareness is legitimately
multi-paragraph, where the router and crash hints are single-line. Newline and
tab are now permitted, every other control character is still rejected, and the
bound is separate at 8 KiB.
2026-08-23 13:18:08 -07:00
|
|
|
def _has_unsafe_control_characters(value: str) -> bool:
|
|
|
|
|
"""Control characters other than newline and tab.
|
|
|
|
|
|
|
|
|
|
Multi-line guidance is legitimate for injected context; raw escapes and NULs are not.
|
|
|
|
|
"""
|
|
|
|
|
return any((ord(ch) < 32 or ord(ch) == 127) and ch not in "\n\t" for ch in value)
|
|
|
|
|
|
|
|
|
|
|
2026-08-20 19:28:19 -07:00
|
|
|
def _has_control_characters(value: str) -> bool:
|
|
|
|
|
return any(ord(character) < 32 or ord(character) == 127 for character in value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _contained(pathname: str, roots: List[str]) -> bool:
|
|
|
|
|
for root in roots:
|
|
|
|
|
try:
|
|
|
|
|
if os.path.commonpath((pathname, root)) == root:
|
|
|
|
|
return True
|
|
|
|
|
except ValueError:
|
|
|
|
|
continue
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validated_post_write_context(payload: Dict[str, Any]) -> Tuple[str, str, int, bytes]:
|
|
|
|
|
cwd_value = payload.get("cwd")
|
fix(cursor): repair hooks against the live Cursor payload contract
Verified against payloads captured from a live Cursor 3.17.8 agent turn. Every
existing adapter test built its own payload, so they all agreed with the
adapter's assumptions rather than with Cursor.
postToolUse(Write) carries no cwd. _validated_post_write_context required it,
raised "unsafe write cwd", and failed open, so the Swift guardrails never ran
in Cursor at all. cwd now falls back to workspace_roots[0], which every
payload does carry; all existing path validation still applies to it.
duration arrives as a float (11554.364 ms). The int-only check dropped it, so
duration_ms never reached the hints child and no duration hint could fire.
Both int and float are accepted and coerced.
Three assumptions were checked and are correct, and are now pinned so they
cannot rot: tool_output is a JSON-encoded string, the Shell variant is keyed
exitCode/output, and workspace_roots is always present.
real-payload.test.ts runs against the generated distribution rather than the
source tree, because the guardrails child is only co-located with the adapter
in the shipped output. The fixture records the captured shape with identity
fields redacted.
Also documents two support boundaries found while verifying: Cursor Free
refuses subagent delegation, so the plugin's agents register but cannot run
there, and plugin hooks load behind Cursor's enable_cc_plugin_import gate,
which clears them silently when off. Both reach the install guide and the
submission runbook, whose smoke matrix could not otherwise be completed on a
free account.
2026-08-23 12:04:14 -07:00
|
|
|
if cwd_value is None:
|
|
|
|
|
# Cursor's postToolUse(Write) payload carries workspace_roots but no cwd
|
|
|
|
|
# (verified against a live Cursor 3.17.8 capture). Fall back to the first
|
|
|
|
|
# workspace root; every validation below still applies to it unchanged.
|
|
|
|
|
roots_value = payload.get("workspace_roots")
|
|
|
|
|
if isinstance(roots_value, list) and roots_value and isinstance(roots_value[0], str):
|
|
|
|
|
cwd_value = roots_value[0]
|
2026-08-20 19:28:19 -07:00
|
|
|
if (
|
|
|
|
|
not isinstance(cwd_value, str)
|
|
|
|
|
or not cwd_value
|
|
|
|
|
or not os.path.isabs(cwd_value)
|
|
|
|
|
or _has_control_characters(cwd_value)
|
|
|
|
|
):
|
|
|
|
|
raise AdapterError("unsafe write cwd")
|
|
|
|
|
try:
|
|
|
|
|
cwd = os.path.realpath(cwd_value)
|
|
|
|
|
except (OSError, ValueError):
|
|
|
|
|
raise AdapterError("unsafe write cwd")
|
|
|
|
|
if not os.path.isdir(cwd):
|
|
|
|
|
raise AdapterError("unsafe write cwd")
|
|
|
|
|
|
|
|
|
|
roots_value = payload.get("workspace_roots")
|
|
|
|
|
roots: List[str] = []
|
|
|
|
|
if roots_value is None:
|
|
|
|
|
roots.append(cwd)
|
|
|
|
|
else:
|
|
|
|
|
if not isinstance(roots_value, list) or not roots_value:
|
|
|
|
|
raise AdapterError("unsafe workspace roots")
|
|
|
|
|
for value in roots_value:
|
|
|
|
|
if (
|
|
|
|
|
not isinstance(value, str)
|
|
|
|
|
or not value
|
|
|
|
|
or not os.path.isabs(value)
|
|
|
|
|
or _has_control_characters(value)
|
|
|
|
|
):
|
|
|
|
|
raise AdapterError("unsafe workspace roots")
|
|
|
|
|
try:
|
|
|
|
|
root = os.path.realpath(value)
|
|
|
|
|
except (OSError, ValueError):
|
|
|
|
|
raise AdapterError("unsafe workspace roots")
|
|
|
|
|
if not os.path.isdir(root):
|
|
|
|
|
raise AdapterError("unsafe workspace roots")
|
|
|
|
|
if root not in roots:
|
|
|
|
|
roots.append(root)
|
|
|
|
|
if not _contained(cwd, roots):
|
|
|
|
|
raise AdapterError("unsafe write cwd")
|
|
|
|
|
|
|
|
|
|
tool_input = payload.get("tool_input")
|
|
|
|
|
if not isinstance(tool_input, dict):
|
|
|
|
|
raise AdapterError("malformed write input")
|
|
|
|
|
file_path = tool_input.get("file_path")
|
|
|
|
|
if (
|
|
|
|
|
not isinstance(file_path, str)
|
|
|
|
|
or not file_path
|
|
|
|
|
or _has_control_characters(file_path)
|
|
|
|
|
):
|
|
|
|
|
raise AdapterError("unsafe write path")
|
|
|
|
|
# Treat both separators as path syntax so the same boundary is safe on POSIX
|
|
|
|
|
# and Windows (where forward slashes are accepted as alternate separators).
|
|
|
|
|
if ".." in re.split(r"[\\/]", file_path):
|
|
|
|
|
raise AdapterError("unsafe write path")
|
|
|
|
|
|
|
|
|
|
unresolved = file_path if os.path.isabs(file_path) else os.path.join(cwd, file_path)
|
|
|
|
|
try:
|
|
|
|
|
file_status = os.lstat(unresolved)
|
|
|
|
|
except (OSError, ValueError):
|
|
|
|
|
raise AdapterError("unsafe write file")
|
|
|
|
|
if stat.S_ISLNK(file_status.st_mode) or not stat.S_ISREG(file_status.st_mode):
|
|
|
|
|
raise AdapterError("unsafe write file")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
resolved_file = os.path.realpath(unresolved)
|
|
|
|
|
except (OSError, ValueError):
|
|
|
|
|
raise AdapterError("unsafe write path")
|
|
|
|
|
if not _contained(resolved_file, roots):
|
|
|
|
|
raise AdapterError("unsafe write path")
|
|
|
|
|
|
|
|
|
|
open_flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
|
|
|
descriptor: Optional[int] = None
|
|
|
|
|
try:
|
|
|
|
|
descriptor = os.open(resolved_file, open_flags)
|
|
|
|
|
opened_status = os.fstat(descriptor)
|
|
|
|
|
if not stat.S_ISREG(opened_status.st_mode):
|
|
|
|
|
raise AdapterError("unsafe write file")
|
|
|
|
|
if opened_status.st_size > MAX_POST_WRITE_FILE_BYTES:
|
|
|
|
|
raise AdapterError("write file too large")
|
|
|
|
|
chunks = bytearray()
|
|
|
|
|
while len(chunks) <= MAX_POST_WRITE_FILE_BYTES:
|
|
|
|
|
chunk = os.read(
|
|
|
|
|
descriptor,
|
|
|
|
|
min(64 * 1024, MAX_POST_WRITE_FILE_BYTES + 1 - len(chunks)),
|
|
|
|
|
)
|
|
|
|
|
if not chunk:
|
|
|
|
|
break
|
|
|
|
|
chunks.extend(chunk)
|
|
|
|
|
if len(chunks) > MAX_POST_WRITE_FILE_BYTES:
|
|
|
|
|
raise AdapterError("write file too large")
|
|
|
|
|
except AdapterError:
|
|
|
|
|
raise
|
|
|
|
|
except (OSError, ValueError):
|
|
|
|
|
raise AdapterError("unsafe write file")
|
|
|
|
|
finally:
|
|
|
|
|
if descriptor is not None:
|
|
|
|
|
try:
|
|
|
|
|
os.close(descriptor)
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
line_count = chunks.count(b"\n")
|
|
|
|
|
if chunks and not chunks.endswith(b"\n"):
|
|
|
|
|
line_count += 1
|
|
|
|
|
return resolved_file, cwd, line_count, bytes(chunks)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sanitized_swift_context(response: Dict[str, Any], line_count: int) -> str:
|
|
|
|
|
hook_output = response.get("hookSpecificOutput")
|
|
|
|
|
if not isinstance(hook_output, dict):
|
|
|
|
|
return ""
|
|
|
|
|
context = hook_output.get("additionalContext")
|
|
|
|
|
if not isinstance(context, str) or not context:
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
findings = {code: set() for _, code, _ in _SWIFT_FINDINGS}
|
|
|
|
|
active_code: Optional[str] = None
|
|
|
|
|
for line in context.splitlines():
|
|
|
|
|
active_code = next(
|
|
|
|
|
(
|
|
|
|
|
code
|
|
|
|
|
for header, code, _ in _SWIFT_FINDINGS
|
|
|
|
|
if line.startswith("⚠️ ") and line.endswith(": " + header)
|
|
|
|
|
),
|
|
|
|
|
active_code,
|
|
|
|
|
)
|
|
|
|
|
if line.startswith("⚠️ ") and not any(
|
|
|
|
|
line.endswith(": " + header) for header, _, _ in _SWIFT_FINDINGS
|
|
|
|
|
):
|
|
|
|
|
active_code = None
|
|
|
|
|
continue
|
|
|
|
|
if active_code is None:
|
|
|
|
|
continue
|
|
|
|
|
match = re.match(r"^([1-9][0-9]{0,8}):", line)
|
|
|
|
|
if match is None:
|
|
|
|
|
continue
|
|
|
|
|
line_number = int(match.group(1))
|
|
|
|
|
if line_number <= line_count:
|
|
|
|
|
findings[active_code].add(line_number)
|
|
|
|
|
|
|
|
|
|
diagnostics = []
|
|
|
|
|
for _, code, message in _SWIFT_FINDINGS:
|
|
|
|
|
diagnostics.extend(
|
|
|
|
|
"{} L{}: {}".format(code, line_number, message)
|
|
|
|
|
for line_number in sorted(findings[code])
|
|
|
|
|
)
|
|
|
|
|
if not diagnostics:
|
|
|
|
|
raise AdapterError("unsupported child output")
|
|
|
|
|
return "\n".join(diagnostics)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def post_write(payload: Dict[str, Any]) -> Dict[str, str]:
|
|
|
|
|
if payload.get("tool_name") != "Write":
|
|
|
|
|
raise AdapterError("unexpected write tool")
|
|
|
|
|
child = os.path.join(SCRIPT_DIRECTORY, "swift-guardrails.py")
|
|
|
|
|
if not os.path.isfile(child):
|
|
|
|
|
raise AdapterError("missing child")
|
fix(cursor): correct is_background emission and fail the build on rewrite rot
transformAgent computed releasedBackground before applying the
CURSOR_FORCED_FOREGROUND exclusion for the emitted frontmatter, but after it
for the capability report. The two disagreed. It is masked today because the
only forced-foreground agent is writable and so is never background-eligible,
but the moment that set includes a read-only agent the shipped frontmatter
would claim is_background: true while the report and pre-deploy assert
foreground. The exclusion is now applied once, before rendering.
rewriteCursorHostClaims strips Claude-host capability claims ("xclog is on
PATH as a bare command, just run it") from canonical prose. Each pattern is
pinned to current canonical wording, so rewording upstream silently stops the
rewrite and ships Cursor users instructions to run a binary the plugin does
not install. An output-side token gate cannot catch this: once the wording
changes the old string is gone and the new one passes through unmatched. The
rewrites are now a table with per-pattern hit tracking, and a render asserts
every pattern matched something. A dead pattern fails the build. All seven
fire on the current corpus.
post_write validated the payload, including reading up to 1 MiB, before
checking the .swift extension, so a large non-Swift write paid that cost and
could report "write file too large". The extension is checked first; a missing
or non-string path still falls through and keeps its diagnostic.
shipped-runtime.test.ts covers a gap: cursorRuntime applies blind string
substitutions over Python source, and every hook test staged the pre-transform
bytes, so the files users actually run were never executed. It compiles all
four shipped runtime scripts and checks the renamed environment variable
agrees between the adapter that sets it and the hints script that reads it.
2026-08-23 12:02:57 -07:00
|
|
|
# Gate on the extension before any filesystem work. Validation reads up to
|
|
|
|
|
# MAX_POST_WRITE_FILE_BYTES, so validating first spends that I/O on files with no
|
|
|
|
|
# guardrails to run and reports "write file too large" for large non-Swift writes.
|
|
|
|
|
#
|
|
|
|
|
# Enforcement is unchanged: this hook is advisory, and no payload reaches the
|
|
|
|
|
# guardrails child on one path but not the other (a symlink is rejected by lstat
|
|
|
|
|
# before the suffix is ever consulted). The tradeoff is observability — for a
|
|
|
|
|
# non-.swift path, unsafe-cwd and workspace-boundary violations are no longer
|
|
|
|
|
# reported to stderr. A missing or non-string file_path still falls through to
|
|
|
|
|
# full validation and keeps its diagnostic.
|
|
|
|
|
tool_input = payload.get("tool_input")
|
|
|
|
|
if isinstance(tool_input, dict):
|
|
|
|
|
raw_path = tool_input.get("file_path")
|
|
|
|
|
if isinstance(raw_path, str) and not raw_path.endswith(".swift"):
|
|
|
|
|
return {}
|
2026-08-20 19:28:19 -07:00
|
|
|
file_path, cwd, line_count, validated_source = _validated_post_write_context(payload)
|
|
|
|
|
if not file_path.endswith(".swift"):
|
|
|
|
|
return {}
|
|
|
|
|
try:
|
|
|
|
|
with tempfile.TemporaryDirectory(prefix="axiom-cursor-swift-") as snapshot_directory:
|
|
|
|
|
snapshot_path = os.path.join(snapshot_directory, "validated.swift")
|
|
|
|
|
with open(snapshot_path, "xb") as snapshot_file:
|
|
|
|
|
snapshot_file.write(validated_source)
|
|
|
|
|
canonical_payload = {
|
|
|
|
|
"tool_name": "Write",
|
|
|
|
|
"tool_input": {"file_path": snapshot_path},
|
|
|
|
|
"cwd": cwd,
|
|
|
|
|
}
|
|
|
|
|
child_output = run_child("swift-guardrails.py", canonical_payload)
|
|
|
|
|
except AdapterError:
|
|
|
|
|
raise
|
|
|
|
|
except OSError:
|
|
|
|
|
raise AdapterError("snapshot failure")
|
|
|
|
|
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")
|
|
|
|
|
context = _sanitized_swift_context(response, line_count)
|
|
|
|
|
return {"additional_context": context} if context else {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def dispatch(mode: str, payload: Dict[str, Any]) -> Dict[str, str]:
|
|
|
|
|
if mode == "session-start":
|
|
|
|
|
return session_start(payload)
|
|
|
|
|
if mode == "post-shell":
|
|
|
|
|
return post_shell(payload)
|
|
|
|
|
if mode == "post-write":
|
|
|
|
|
return post_write(payload)
|
feat(cursor): port per-prompt router injection to beforeSubmitPrompt
The capability ledger recorded UserPromptSubmit as "omitted" with no stated
rationale, which dropped Axiom's core routing-enforcement mechanism and left
skill activation in Cursor resting entirely on description-based discovery.
Cursor supports the capability.
Its beforeSubmitPrompt event carries the prompt text and its response accepts
additional_context — the same mechanism user-prompt-submit.py already emits
through hookSpecificOutput.additionalContext. Verified against a live Cursor
3.17.8 session, not inferred: an injected marker token came back at the head of
the model's reply, and with this adapter mode in place the prompt "My SwiftUI
list scrolls badly on device" is routed to axiom-swiftui, which the model then
opens before answering.
The adapter gains a prompt-submit mode that translates the payload, runs the
canonical router as a child, and returns its guidance as additional_context.
The router child now ships with the distribution.
Two details worth noting:
_workspace_root centralises the fact that Cursor omits cwd on several events
but always sends workspace_roots. Children run their own project gate against
the directory they start in, so the router child is now started in the
workspace rather than the plugin directory. session_start uses the same helper.
Child output is not forwarded blindly. The router emits a fixed template naming
skills from its own table and never prompt text, so the adapter requires that
shape, bounds the length, and rejects control characters before the string
reaches the model's turn.
Also stops the hooks writing __pycache__ into the plugin directory Cursor
manages, via sys.dont_write_bytecode and -B on the child spawn.
2026-08-23 12:38:09 -07:00
|
|
|
if mode == "prompt-submit":
|
|
|
|
|
return prompt_submit(payload)
|
2026-08-23 13:09:08 -07:00
|
|
|
if mode == "pretool-read":
|
|
|
|
|
return pretool_read(payload)
|
feat(cursor): port subagent skill awareness to subagentStart
The third and last capability the ledger recorded as downgraded, replaced by a
static preamble in each agent file. Cursor's subagentStart response accepts
additional_context and its query carries subagent_type, which is the only field
the canonical hook reads, so the port is a field rename.
This is additive, not a replacement. The generated preamble carries an agent's
own declared skills; the hook carries the general skill-awareness text gated by
agent type. Both now ship.
Verified as far as a free Cursor plan allows: the hook registers, fires, and
delivers a payload carrying subagent_type, task, and subagent_model — confirmed
against a live Cursor 3.17.8 delegation, which fires subagentStart three times
before refusing to start the subagent with "Named models unavailable. Free
plans can only use Auto." What a free plan cannot show is whether the returned
context reaches a subagent that never starts. The docs and the submission
matrix say so rather than implying full verification. If delivery does not
work, the preamble still carries the declared skills, so the failure mode is
the status quo rather than a regression.
The context guard needed widening: skill awareness is legitimately
multi-paragraph, where the router and crash hints are single-line. Newline and
tab are now permitted, every other control character is still rejected, and the
bound is separate at 8 KiB.
2026-08-23 13:18:08 -07:00
|
|
|
if mode == "subagent-start":
|
|
|
|
|
return subagent_start(payload)
|
2026-08-20 19:28:19 -07:00
|
|
|
raise AdapterError("unknown mode")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> int:
|
|
|
|
|
response: Dict[str, str] = {}
|
|
|
|
|
try:
|
|
|
|
|
if len(sys.argv) != 2:
|
|
|
|
|
raise AdapterError("unknown mode")
|
|
|
|
|
response = dispatch(sys.argv[1], read_payload())
|
|
|
|
|
except AdapterError as error:
|
|
|
|
|
diagnostic(str(error))
|
|
|
|
|
except Exception:
|
|
|
|
|
diagnostic("internal failure")
|
|
|
|
|
print(json.dumps(response, separators=(",", ":")))
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
sys.exit(main())
|