magus v0.4.3 is out. See what's new
¶ View markdown source · ✎ Suggest an edit
21 min read

Cursor

Cursor does not read Agent Skills directories. It reads an AGENTS.md at the repository root, and it runs hooks as programs with the event on stdin. One self-contained script covers every event magus uses, so installing the whole integration is a single download.

what where
always-on rules AGENTS.md (you paste the block; magus never writes it)
guard wiring .cursor/hooks.json
command surface deny and advise both reach the model
file surface deny and advise both reach the model
MCP call surface not wired: beforeMCPExecution/afterMCPExecution exist, their payload does not (see below)
checkpoint sessionEnd
lease subagentStart (unverified live, see below)
MCP MCP

Skills

There is no skills directory to install into. Run magus agent install anyway: it prints the managed magus block when your AGENTS.md is missing it or carrying a stale one, and you paste it in. Skills covers the block, its stamp, and the drift check that grades it.

Because Cursor has no Agent Skills surface, it cannot enforce a short-versus-full skill-form choice. Keep that repository guidance explicit and user-owned in AGENTS.md; do not claim a model or provider setting selects it automatically.

An advise verdict reaches the model here too, on postToolUse rather than at the moment the call is gated; that guidance being in AGENTS.md as well is the same belt-and-braces every host gets, not a substitute for it.

MCP

MCP client config is yours. Harness apply only prints a short hint (and a docs pointer); Magus does not write .cursor/mcp.json.

magus server start
magus config mcp connector create --name cursor --expires 366d   # shown once: store it as MAGUS_MCP_TOKEN
magus agent harness apply --id cursor   # prints setup hint; wires hooks only

Then register Magus under Cursor Settings -> Tools & MCP (or hand-write .cursor/mcp.json / ~/.cursor/mcp.json). Endpoint http://127.0.0.1:7391/mcp; bind the token with ${env:MAGUS_MCP_TOKEN} if you prefer env interpolation. Restart Cursor after changing the client config. magus status --probe=mcp must report serving. Full notes: MCP. An agent uses the CLI fallback when MCP is unavailable; it does not manually start Magus solely to obtain tools.

Guard hook

Prefer wiring the Cursor harness from the root magusfile when you bounce between hosts; apply then covers every wired provider:

import "ghcr.io/egladman/magus/spells/cursor";
magus\harness.provider(cursor);

Declare the spell in magus.yaml with the tag cd's spell-publish step pushed, and run your lock target with :update to pin its digest in magus.lock, so the harness versions apart from your magus binary; see Remote spells.

magus agent harness apply
magus agent harness verify

To adapt that Buzz harness without modifying Magus source: copy the spell into the workspace, change only the import path (for example import "harness/cursor" as cursor), edit the workspace Buzz, then re-run apply and verify. Details: Adapting a Buzz harness and Recurring guard friction.

Or target Cursor alone:

magus agent harness apply --id cursor
magus agent harness verify --id cursor

That writes opaque fragments naming sh docs/guides/integrations/agents/cursor-hook.sh --agent-name cursor. The script takes the host's name from that argument and nowhere else, and refuses a call without it (MGS3024). A portable install copies the script to .cursor/hooks/cursor-hook.sh, makes it executable, and points every event at that copy, name included:

{
  "version": 1,
  "hooks": {
    "beforeShellExecution": [{ "command": "./.cursor/hooks/cursor-hook.sh --agent-name cursor" }],
    "preToolUse": [{ "matcher": "Write|StrReplace|Delete|Edit|NotebookEdit", "command": "./.cursor/hooks/cursor-hook.sh --agent-name cursor" }],
    "postToolUse": [
      { "matcher": "Shell|Write|StrReplace|Delete|Edit|NotebookEdit|Grep|Glob|Read|WebSearch|WebFetch", "command": "./.cursor/hooks/cursor-hook.sh --agent-name cursor" }
    ],
    "subagentStart": [{ "command": "./.cursor/hooks/cursor-hook.sh --agent-name cursor" }],
    "sessionEnd": [{ "command": "./.cursor/hooks/cursor-hook.sh --agent-name cursor" }]
  }
}

It is deliberately self-contained rather than delegating to the shared templates: needing five files to install a guard is how a guard ends up not installed. The script branches on the hook_event_name every Cursor hook carries, so one file serves all of them.

Cursor splits across two events what every other host delivers from one, and that is the thing to read before changing this wiring. A gating event (beforeShellExecution, preToolUse) carries a deny, because Cursor sends user_message and agent_message only with a denial. An advise therefore has no channel there and needs postToolUse.additional_context, which arrives after the call. So a judged call runs magus twice on this host and leaves two rows in the activity trail, where every other host leaves one.

A push at a commit no passing gate covers gets the verdict ask, and beforeShellExecution answers permission: "ask": Cursor shows you the reason, which names the commit and the gate state, and approving publishes it. A push the gate covers is allowed without a prompt. A session bound to a job lease is denied, because workers do not publish. Any decision the script does not know is denied, never allowed.

The matcher values are a regex (Cursor's own validator: outside "" and "*" the string must compile). One entry per event is enough: the script is the same file on every arm by design, and it branches on payload shape. Scope the regex to tools that carry a command, a path, a Grep/Glob pattern, a Read path, or a WebSearch / WebFetch query; a postToolUse with none of those returns {}. Grep, Glob, and Read never reach beforeShellExecution, so they ride postToolUse and are restated as the shell shapes those rules already know (rg …, find . -name …, cat … for an unbounded Read, sed -n 'a,bp' when Read already carries a limit). WebSearch and WebFetch also ride postToolUse: when kind=link has citations matching the query, the script injects those URLs as additional_context so the next open-web look prefers package/docs hosts this tree already depends on. Cursor Agent tools spell the path field path; the script also accepts file_path.

#!/usr/bin/env sh
# magus guard for Cursor. ONE file, every event; download only this.
#
# Cursor runs a hook as a PROGRAM with the event as JSON on stdin. Its events carry
# different payloads, so this reads the event once and branches on the
# hook_event_name every one of them carries:
#
#   beforeShellExecution  {"command": "...", "cwd": "...", "sandbox": false}
#   preToolUse            {"tool_name": "...", "tool_input": {"path": "..."}}
#   postToolUse           the same, plus tool_output
#   subagentStart         {"subagent_type": "...", "task": "...", ...}
#   sessionEnd            {"session_id": "...", "reason": "..."}
#
# Save to .cursor/hooks/cursor-hook.sh, chmod +x, and point them at it:
#
#   {"version": 1, "hooks": {
#     "beforeShellExecution": [{"command": "./.cursor/hooks/cursor-hook.sh --agent-name cursor"}],
#     "preToolUse":   [{"matcher": "Write|StrReplace|Delete|Edit|NotebookEdit", "command": "./.cursor/hooks/cursor-hook.sh --agent-name cursor"}],
#     "postToolUse":  [{"matcher": "Shell|Write|StrReplace|Delete|Edit|NotebookEdit|Grep|Glob|Read|WebSearch|WebFetch", "command": "./.cursor/hooks/cursor-hook.sh --agent-name cursor"}],
#     "subagentStart": [{"command": "./.cursor/hooks/cursor-hook.sh --agent-name cursor"}],
#     "sessionEnd":   [{"command": "./.cursor/hooks/cursor-hook.sh --agent-name cursor"}]}}
#
# Self-contained on purpose. The other hosts' templates delegate to
# magus-command.sh, but Cursor would then need three files downloaded to
# work, and a guard nobody finishes installing guards nothing.
#
# WHICH EVENT CARRIES WHICH HALF of a verdict is the thing to read here, because
# Cursor splits across two events what every other host delivers from one:
#
#   - A DENY needs a gating event. beforeShellExecution gates a command;
#     preToolUse gates a write and blocks it BEFORE it lands, which afterFileEdit,
#     the event this file used to read, could never do.
#   - An ADVISE needs a context channel, and a gating event has none: Cursor
#     delivers user_message and agent_message only on a deny, so an advisory sent
#     there collapses into a plain allow. postToolUse's additional_context is the
#     channel, and it arrives after the call rather than before it. For the rules
#     that advise (generated files, a search the graph answers better) reporting
#     after the fact is the intended behavior on every host, and what Cursor shapes
#     is only which event carries it.
#
# So a judged call runs magus TWICE here: once to gate it, once to explain it. That
# is the price of the split, and it is why the activity trail carries two rows per
# call on this host and one everywhere else.
#
# Every call passes on the --agent-name this script was given (`--agent-name cursor`, from
# the configuration `magus agent harness apply` writes) so the observation magus records
# says which host produced it; a config that names none is refused (MGS3024). Cursor carries conversation_id on every hook and session_id on
# the session ones, so the session is attributable too; neither can change a verdict.
#
# Coverage declarations, machine-read by the host-parity gate - see the longer
# note in magus-command.sh. Both surfaces now reach the model on both
# decisions, which is what moving the write gate to preToolUse and the advisory to
# postToolUse bought; the two lines are what says so.
# magus-guard-template: 18
# magus-guard-coverage: schema=1 host=cursor surface=command deny=model advise=model pass=none ask=human
# magus-guard-coverage: schema=1 host=cursor surface=path deny=model advise=model pass=none ask=human
# magus-guard-coverage: schema=1 host=cursor surface=mcp deny=none advise=none pass=none ask=none
# NOT because the transport is missing: testdata/hosts/cursor/hooks.schema.json DOES
# declare beforeMCPExecution and afterMCPExecution, the MCP-call twins of beforeShellExecution
# and preToolUse/postToolUse above. What is missing is the PAYLOAD: no vendored source (Cursor
# ships no schema for it, only the config-shape validator the rows above are transcribed from)
# says what field carries the tool name and params on those two events, and this script does
# not guess at one - wiring a guard against an unverified field name is the exact silent-failure
# class this whole contract exists to catch (see subagentStart's own "unverified live" note
# below). Flip this the day Cursor documents, or this file verifies, that payload.

# Prefer the workspace's own ./magus over PATH. A repository that builds magus, or pins a
# newer one than is installed, keeps its RULES in that binary - and an older PATH copy does
# not fail loudly when it lacks them. It does not recognize the config key that ARMS a rule,
# warns about an unknown field, and returns pass: silent non-enforcement at exit 0. Measured
# 2026-08-13, when a write into a declared notes store was allowed by a binary that predated
# the knowledge.notes key while `magus doctor` reported the guard as fine.
#
# Found by walking UP to the magusfile, not by testing ./magus alone. A hook runs in the
# host's session directory, and that is not always the workspace root: a session opened in
# a subdirectory, or opened in one checkout while the work happens in another, tests a
# ./magus that is not there and falls through to PATH. Where PATH's copy cannot load the
# workspace at all, that is the entire guard failing open - measured 2026-08-27, when a
# piped `magus affected ci` that the rules DO deny ran unjudged. Same upward search for a
# project root that every other ecosystem's runner does.
guard_root=$PWD
while [ -n "$guard_root" ] && [ -z "$__MAGUS_BIN" ]; do
  if [ -f "$guard_root/magusfile.buzz" ]; then
    [ -x "$guard_root/magus" ] && __MAGUS_BIN=$guard_root/magus
    break
  fi
  guard_root=${guard_root%/*}
done
[ -n "$__MAGUS_BIN" ] || __MAGUS_BIN=$(command -v magus 2>/dev/null)

# The host this entry is wired into, from the entry's own argv and nowhere else; the
# configuration `magus agent harness apply` writes passes `--agent-name cursor`.
agent_name=
while [ $# -gt 0 ]; do
  case $1 in
    --agent-name) agent_name=${2-}; [ $# -ge 2 ] && shift; shift ;;
    --agent-name=*) agent_name=${1#--agent-name=}; shift ;;
    *) shift ;;
  esac
done

# stdin is a pipe and drains once, so the event is read into a variable and every
# field is selected from that. `// empty` keeps a hook without a field at the empty
# string rather than at the literal "null".
event=$(cat)
event_name=$(printf '%s' "$event" | jq -r '.hook_event_name // empty' 2>/dev/null)
session=$(printf '%s' "$event" | jq -r '.session_id // .conversation_id // empty' 2>/dev/null)
transcript=$(printf '%s' "$event" | jq -r '.transcript_path // empty' 2>/dev/null)
shell_command=$(printf '%s' "$event" | jq -r '.command // empty' 2>/dev/null)
tool_command=$(printf '%s' "$event" | jq -r '.tool_input.command // empty' 2>/dev/null)
path=$(printf '%s' "$event" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
# Cursor's Grep/Glob/Read tools never reach beforeShellExecution, so the search-
# and source-read family guard rules would miss them unless we restate them as the
# shell shapes those rules already judge. Scoped Grep stays a narrow read
# (rg pattern file); a workspace-wide Grep becomes bare rg. An unbounded Read
# becomes cat; a Read that already carries offset/limit becomes sed -n so the
# source-read advisory stays quiet for a bounded range.
# Cursor Agent tools spell the path field `path`; older hook docs said file_path.
search_command=$(printf '%s' "$event" | jq -r '
  if .tool_name == "Grep" and (.tool_input.pattern // "") != "" then
    ( .tool_input.path // .tool_input.file_path // "" ) as $p |
    if $p != "" and $p != "." then
      "rg \(.tool_input.pattern | @sh) \($p | @sh)"
    else
      "rg \(.tool_input.pattern | @sh)"
    end
  elif .tool_name == "Glob" and ((.tool_input.glob_pattern // .tool_input.glob // "") != "") then
    "find . -name \((.tool_input.glob_pattern // .tool_input.glob) | @sh)"
  elif .tool_name == "Read" and ((.tool_input.path // .tool_input.file_path // "") != "") then
    ( .tool_input.path // .tool_input.file_path ) as $p |
    ( .tool_input.offset // 0 | tonumber ) as $o |
    ( .tool_input.limit // 0 | tonumber ) as $l |
    if $l > 0 then
      (if $o > 0 then $o else 1 end) as $start |
      ($start + $l - 1) as $end |
      "sed -n \("\($start),\($end)p" | @sh) \($p | @sh)"
    else
      "cat \($p | @sh)"
    end
  else empty end
' 2>/dev/null)
# WebSearch/WebFetch: bias the NEXT open-web look toward kind=link citations this
# workspace already depends on (package docs URLs, upstream references). Not a
# deny and not magus's own site: prefer site:<host> / those URLs so results stay
# on packages the tree cites. Empty match stays silent.
link_bias_query=$(printf '%s' "$event" | jq -r '
  if .tool_name == "WebSearch" then
    (.tool_input.search_term // .tool_input.query // .tool_input.search_query // empty)
  elif .tool_name == "WebFetch" then
    (.tool_input.url // empty)
  else empty end
' 2>/dev/null)

# A payload naming no event is judged by SHAPE instead. Branching on the name is
# what lets one file serve five events, and a Cursor that stopped sending the field
# would otherwise take every arm below to the silent default, which is the one
# failure this guard cannot afford, since it looks exactly like a clean session.
if [ -z "$event_name" ]; then
  if [ -n "$shell_command" ]; then
    event_name=beforeShellExecution
  elif [ -n "$path" ]; then
    event_name=preToolUse
  fi
fi

# A config that names no host is refused, never defaulted: the gating events are denied,
# and every event says why on stderr. The write and shell gates are where an unguarded
# call would slip through, so they fail closed here rather than allowing.
if [ -z "$agent_name" ]; then
  unnamed="[MGS3024] this hook was not given --agent-name, so nothing was judged. Run \`magus agent harness apply\` to rewrite the host's hook configuration; the commands it writes name the host."
  printf 'cursor-hook.sh: %s\n' "$unnamed" >&2
  case $event_name in
  beforeShellExecution | preToolUse)
    printf '{"permission":"deny","user_message":"%s","agent_message":"%s"}' "$unnamed" "$unnamed"
    ;;
  subagentStart)
    printf '%s' '{"permission":"allow"}'
    ;;
  esac
  exit 0
fi

# The two replies Cursor reads. A deny carries BOTH messages: user_message is shown
# to the person and agent_message reaches the model. Neither is delivered on an
# allow, which is why the advisory lives on a different event.
#
# An ask is Cursor's own approval prompt: the person sees user_message and decides. Only pass
# and advise allow. Anything else, including a decision this file does not know, is refused,
# because a copy older than the guard contract must not read a new verdict as consent.
gate_template='{{if eq .decision "deny"}}{"permission":"deny","user_message":{{toJson .reason}},"agent_message":{{toJson .reason}}}{{else if eq .decision "ask"}}{"permission":"ask","user_message":{{toJson .reason}},"agent_message":{{toJson .reason}}}{{else if eq .decision "pass"}}{"permission":"allow"}{{else if eq .decision "advise"}}{"permission":"allow"}{{else}}{"permission":"deny","user_message":{{toJson (print "magus guard returned the decision " .decision ", which this hook does not know. Update .cursor/hooks/cursor-hook.sh from the magus docs.")}},"agent_message":{{toJson (print "magus guard returned the decision " .decision ", which this hook does not know, so it refuses the call rather than allow it.")}}}{{end}}'
advise_template='{{if eq .decision "advise"}}{"additional_context":{{toJson .context}}}{{else}}{}{{end}}'

# guard_notice_once succeeds the first time $1 fires in this session and fails on every
# repeat, so a caller writes `guard_notice_once <family> && printf ...`. See
# magus-command.sh for the full reasoning; the short version is that these notices
# report a broken installation, which is a fact for the person with nothing in it an agent
# can act on, so a repeat is noise.
#
# The marker lives under TMPDIR because this runs when magus is missing or too broken to
# judge, so it cannot ask magus for anything. An event that reports no session shares a
# marker aged out after __MAGUS_NOTICE_WINDOW minutes rather than going quiet forever.
guard_notice_once() {
  notice_dir=${TMPDIR:-/tmp}/magus-guard-notices
  notice_key=$(printf '%s' "${session:-anon}" | cksum | cut -d' ' -f1)
  notice_marker=$notice_dir/$notice_key.$1
  mkdir -p "$notice_dir" 2>/dev/null || return 0
  if [ -f "$notice_marker" ]; then
    [ -n "$session" ] && return 1
    find "$notice_marker" -mmin +"${__MAGUS_NOTICE_WINDOW:-120}" 2>/dev/null | grep -q . || return 1
  fi
  : > "$notice_marker" 2>/dev/null
  return 0
}

# guard pipes its first argument into `magus session hook` with the rest as flags. The
# thing being judged goes in on STDIN, never in argv: a command is arbitrary text, and
# one passed as an argument is a quoting mistake away from being re-parsed.
guard() {
  guard_input=$1
  shift
  printf '%s' "$guard_input" | "$__MAGUS_BIN" shell --agent-name "$agent_name" \
    --session "$session" --transcript "$transcript" "$@"
}

# link_bias_context prints a Cursor additional_context JSON object when kind=link
# has citations matching $1, or prints nothing and fails when it does not. Caps
# at eight URLs so a broad query does not dump the whole citation index.
link_bias_context() {
  terms=$1
  [ -n "$terms" ] || return 1
  [ -n "$__MAGUS_BIN" ] && [ -x "$__MAGUS_BIN" ] || return 1
  links=$("$__MAGUS_BIN" query kind=link "$terms" -o name 2>/dev/null) || return 1
  [ -n "$links" ] || return 1
  printf '%s\n' "$links" | jq -R -s -c --arg q "$terms" '
    (split("\n") | map(select(length > 0) | sub("^link:"; "")) | .[0:8]) as $urls
    | if ($urls | length) == 0 then empty else
      {
        additional_context: (
          "This workspace already cites related docs (kind=link). Prefer these over a broad web search so results stay on packages and references this tree depends on:\n"
          + ($urls | map("  - " + .) | join("\n"))
          + "\nRefine the next search with site:<host> from those URLs, or WebFetch one directly. List them again: ./magus query kind=link "
          + ($q | @sh)
          + " -o name"
        )
      }
    end
  ' 2>/dev/null
}

# guard_failure_notice states WHICH binary went silent, what version it is, and what it
# actually said, the three facts a reader otherwise spends a session collecting. It takes
# the same arguments the failed call did, and re-runs it to capture the stderr the verdict
# path discards: one extra process, only on the path that is already broken. WARN lines are
# dropped because a config the binary is too old to parse warns BEFORE it fails, and that
# warning is a symptom of the same staleness rather than the error.
#
# Held to one firing per session, and printed on stderr, which Cursor surfaces: a broken
# installation is a fact for the person, and there is nothing in it a model can act on.
guard_failure_notice() {
  guard_notice_once failed || return 0
  ver=$("$__MAGUS_BIN" version 2>/dev/null | head -n 1)
  [ -n "$ver" ] || ver='version unreadable'
  why=$(guard "$@" 2>&1 >/dev/null | grep -v 'WARN' | head -n 1)
  [ -n "$why" ] || why='it printed no error'
  printf 'magus guard is NOT running: %s (%s) could not judge this call, so its deny and advise rules are unenforced. It said: %s. Rebuild or update THAT binary to restore the guard.\n' \
    "$__MAGUS_BIN" "$ver" "$why" >&2
}

# jq is the only reader of the event: without it every field selected above came back
# empty, the shape fallback had nothing to infer from, and every arm below would reach
# the silent default, which looks exactly like a guarded session. Announce it and answer
# the gating shape explicitly rather than infer an event this cannot read.
if ! command -v jq >/dev/null 2>&1; then
  guard_notice_once nojq && printf '%s\n' "magus guard is NOT running: jq is not on PATH, so this hook cannot read the event and its deny and advise rules are unenforced right now. Install jq to restore the guard." >&2
  printf '%s' '{"permission":"allow"}'
  exit 0
fi

# One availability check for every arm. Cursor already fails open on a hook crash or
# malformed JSON unless the hook sets failClosed, so allowing here matches the
# surrounding contract rather than pretending to be stricter than it; for strict
# behavior, set failClosed on the hook and answer deny instead. What was missing was
# saying so: a silent fail-open is the one outcome nobody can tell from a guarded
# session. The gating events need an explicit allow, and the rest read an empty reply
# as no opinion.
if [ -z "$__MAGUS_BIN" ] || [ ! -x "$__MAGUS_BIN" ]; then
  guard_notice_once unavailable && printf '%s\n' "magus guard is NOT running: magus is not on PATH, so its deny and advise rules are unenforced right now. Install magus, or set __MAGUS_BIN to its path, to restore the guard." >&2
  case $event_name in
  beforeShellExecution | preToolUse | subagentStart)
    printf '%s' '{"permission":"allow"}'
    ;;
  esac
  exit 0
fi

# Every verdict below is captured and printed rather than piped straight through,
# because `magus session hook` exits non-zero on a deny and Cursor reads a non-zero
# hook as a CRASH, which it fails open on unless failClosed is set. Letting that
# status escape would turn every block into an allow, silently, which is the one
# outcome worse than not installing the guard. Cursor's channel is the JSON on
# stdout, and this exits 0 so that JSON is what it acts on.
#
# An empty verdict is a BROKEN guard, never a pass: the templates above render a
# reply for every decision, so nothing but a magus that could not run leaves one
# empty: too old for `session hook`, unable to load the workspace, half-written by
# a concurrent build. Allowing is still right; announcing it is what was missing.
case $event_name in
sessionEnd)
  # Not a guard. It records the revision, branch and dirtiness of the tree when a
  # session ends, so whoever comes back reads `magus session` instead of
  # reconstructing where the work stopped. It prints nothing and judges nothing.
  "$__MAGUS_BIN" session checkpoint --agent-name "$agent_name" \
    --session "$session" --transcript "$transcript" >/dev/null 2>&1
  exit 0
  ;;
subagentStart)
  # Lease capture, reshaped rather than piped: magus recognizes a spawn by a
  # tool_input carrying a prompt, and Cursor spells the handed work `task` at the
  # top level. The parent is parent_conversation_id, since conversation_id here is
  # the child's. Nothing judges a lease: a prompt is prose, so the verdict is
  # always a pass, the output is discarded, and this arm always allows.
  printf '%s' "$event" | jq -c '{
      hook_event_name: (.hook_event_name // ""),
      session_id: (.parent_conversation_id // .conversation_id // ""),
      transcript_path: (.transcript_path // ""),
      tool_input: {prompt: (.task // ""), subagent_type: (.subagent_type // "")}
    }' 2>/dev/null | "$__MAGUS_BIN" shell --agent-name "$agent_name" >/dev/null 2>&1
  printf '%s' '{"permission":"allow"}'
  exit 0
  ;;
postToolUse)
  # The advise channel, for whichever surface the payload names. It renders {} on
  # anything that is not an advise, so Cursor always gets a reply it can parse.
  # Grep/Glob/Read land here (not beforeShellExecution): see search_command above.
  # WebSearch/WebFetch land here too: see link_bias_query above.
  if [ -n "$tool_command" ]; then
    verdict=$(guard "$tool_command" -o "template=$advise_template" 2>/dev/null)
    if [ -z "$verdict" ]; then
      guard_failure_notice "$tool_command"
      verdict='{}'
    fi
  elif [ -n "$search_command" ]; then
    verdict=$(guard "$search_command" -o "template=$advise_template" 2>/dev/null)
    if [ -z "$verdict" ]; then
      guard_failure_notice "$search_command"
      verdict='{}'
    fi
  elif [ -n "$link_bias_query" ]; then
    verdict=$(link_bias_context "$link_bias_query")
    [ -n "$verdict" ] || verdict='{}'
  elif [ -n "$path" ]; then
    verdict=$(guard "$path" --path -o "template=$advise_template" 2>/dev/null)
    if [ -z "$verdict" ]; then
      guard_failure_notice "$path" --path
      verdict='{}'
    fi
  else
    verdict='{}'
  fi
  printf '%s' "$verdict"
  exit 0
  ;;
preToolUse)
  # The write gate. Cursor's preToolUse fires for every tool, so this answers on the
  # SHAPE of the payload rather than on a tool name: a tool_input carrying a
  # file_path is a write. The shell tool reaches this hook too, carrying
  # tool_input.command, and is deliberately left to beforeShellExecution, because
  # judging it here as well would record two verdicts for one command.
  if [ -z "$path" ]; then
    printf '%s' '{"permission":"allow"}'
    exit 0
  fi
  # --renders-ask on the gating events only: gate_template answers an ask with Cursor's
  # own prompt, and advise_template has no ask arm, so a postToolUse call makes no claim.
  verdict=$(guard "$path" --path --renders-ask -o "template=$gate_template" 2>/dev/null)
  if [ -z "$verdict" ]; then
    guard_failure_notice "$path" --path
    verdict='{"permission":"allow"}'
  fi
  ;;
beforeShellExecution)
  verdict=$(guard "$shell_command" --renders-ask -o "template=$gate_template" 2>/dev/null)
  if [ -z "$verdict" ]; then
    guard_failure_notice "$shell_command"
    verdict='{"permission":"allow"}'
  fi
  ;;
*)
  # An event this file does not serve. An empty reply is no opinion.
  exit 0
  ;;
esac

printf '%s' "$verdict"
exit 0

Notifications

Cursor can run a command on its agent hook surface. Shape the event into the canonical envelope and pipe it to magus session notify; see Attention hooks.

Recording where the work stands

The sessionEnd entry in the wiring above records the revision, branch and dirtiness of the tree when a session ends; magus session lists it. The script handles that arm itself, calling magus session checkpoint rather than the shared magus-checkpoint.sh, so this host stays a one-file install; the shared template does the identical job if you would rather point sessionEnd at it with --agent-name cursor on the command.

sessionEnd carries session_id and every hook carries transcript_path, so both pointers are recorded. They are pointers magus stores and never opens, and a checkpoint without them still says where the work sits, which is the part a person coming back needs.

magus session checkpoint --note "..." writes the same record by hand.

Lease capture

subagentStart fires when Cursor hands work to a sub-agent, and carries the handed task, the subagent_type, and parent_conversation_id for the parent side. That is everything magus records as a spawn, under different names, so the script reshapes the payload into the canonical envelope before piping it: magus recognizes a spawn by a tool_input carrying a prompt, never by a tool name it would have to enumerate per host.

It records; it does not judge. A lease prompt is prose, so the verdict is always a pass and the arm always allows. To join those events to a job, write the marker line documented in Any other host at the top of the prompt you hand the sub-agent.

This one is unverified live. An open Cursor forum report says subagentStart and subagentStop never fire while beforeShellExecution from the same hooks.json works normally. The wiring ships anyway, because a hook that never fires costs nothing and a missing one cannot be found; check magus session for agent_spawn events before relying on it.

Coverage and limits

The MCP call surface is declared but not wired. Cursor's hooks schema DOES name beforeMCPExecution and afterMCPExecution - the MCP-call twins of beforeShellExecution and preToolUse/postToolUse above - so this is not the "transport does not carry it" gap it is on Codex and OpenCode. What is missing is the payload: no schema, published or transcribed, says what field on those two events carries the tool name and params, and this script does not wire an event whose shape it cannot verify - the same caution subagentStart below already gets ("unverified live"). Confirm the payload against a real Cursor session before flipping this.

Both surfaces now reach the model on both decisions. That is new, and it cost two events per judged call: the write gate moved from afterFileEdit, which fires once the write has landed, to preToolUse, which blocks it; and the advisory moved from stderr prose to postToolUse.additional_context. Reporting a declared-output edit after the call is not a concession, since that rule only ever explains, on every host. Reporting it to the PERSON was, and that is what changed.

Judging twice is the price. A gating event carries no message on an allow, so the explanation has to come from a second event, and magus is asked about the same call twice. The activity trail therefore carries two rows per judged call here.

Post-compaction rehydration is not expressible. Every other host has an event that hands a compacted session its state back. Cursor's preCompact returns user_message only, which reaches the person and not the model, and beforeSubmitPrompt explicitly cannot inject context. So there is nothing to wire and nothing is faked: run magus session --brief and paste it, or read it yourself. sessionStart.additional_context does reach the model, but it fires when nothing has been lost yet.

A tool failure carries no hint. postToolUseFailure has no response fields at all, so the one place a host could explain a failing command is closed here.

Cursor fails open on a hook crash or malformed JSON unless the hook sets failClosed. The script above matches that stance instead of pretending to be stricter than the surrounding contract. For strict behavior, set failClosed on the hook and change its missing-binary branch to a deny.

The magus half is verified: the verdict shape this script parses is checked against this repository's binary. The Cursor half is written against the product's published hook documentation and has not been executed here, so confirm it against Cursor hooks.

There is also no session-load adapter for this host, where the other three ship one. Nothing in Cursor prevents it; nobody has written it.

Verify

magus doctor

guard binary names the binary a hook would resolve; guard wiring probes it with a known-denied command and checks that a host config invokes a template whose version marker is current.

agentscursorAGENTS.mdguardhooks
Last updated (a9ff8609)
Earlier changes on this page (7)

Full history ↗ · Blame source ↗

Glossary

Workspace

The magus root directory that owns a set of projects and shared config; the unit magus operates over. See workspace.

Project

A directory magus recognizes as a unit of work (it has a magusfile); the unit of caching, scheduling, and dependency tracking. See workspace.

Magusfile

The magusfile.buzz that declares a project's targets (as export funs) and binds its spells. See targets.

Target

A named operation (build, test, ...) you invoke with magus run <target>; it may compose a spell's tool-native operations and depend on other targets. See targets.

Op

A single tool-native command a target composes (long form: operation); the middle of the work hierarchy (Spell to Op to Target). See operations.

Spell

A language/runtime adapter (e.g. go, md) that maps generic targets onto a toolchain's real commands. See spells.

Ward

A coded diagnostic that inspects a resolved op and nudges or blocks an anti-pattern before it runs. See wards.

Buzz

The language magusfiles are written in (the .buzz engine). See engines.

Affected

The set of projects touched by a change; magus affected <target> runs a target only over them. See affected.

Sandbox

The restricted filesystem and environment a target runs in, so builds stay reproducible and side-effect-free. See sandbox.

Server

The background process a person starts with magus server start. It serves MCP, the console, background jobs and the warm knowledge graph, and adopts nested magus calls into one pool. See server.

CI

An ordinary magusfile-defined target you compose yourself with magus\needs - magus does not hardcode its stages. Magus.RunCI treats it specially only in that it strips the rw charm, it is the anchor magus affected ci keys off, and a selected scope with no project declaring it is a load error rather than a silent no-op. See targets.

Session

An agent host's conversation, by the id the host delivers to its hooks. magus never mints one: a record with no session is unattributed, and the OS user it carries says whose account ran it.

Window

The terminal a command runs in. It keys fire-once notices for a caller no host gave a session, and is never recorded as a session.

Job

The unit of delegated work, and one row of the job store: what an orchestrating agent handed out, with its goal, the checkpoint it was cut against, the paths it may write or must not touch, and the one check it runs. A job's holder is either a session, for work an orchestrator handed out, or the server, for its own maintenance. The store records; the agent guard is what reads those facts back when grading a write. See doctrine.

A job is not a run. magus run build web is a run, and no job exists for it. A job causes runs: its check executes as one, and a server job records the invocation of its last one. Jobs are listed with magus ls jobs and in the console's Jobs view; runs are listed in the Runs view.

Run

One target executing under one magus invocation, such as magus run test web or magus affected ci. A run keeps its captured output behind an output reference. Every magus run is a run whether or not any job asked for it; see Job for how the two relate.

Lease

The grant a holder takes on a job: the write and read paths that job declared, enforced in the checkout that took it with magus job exec. A job is the piece of work; a lease is permission over it.

Advisor

One read-only check from the advice suite: it reads the changeset through magus and writes one titled section of findings. The same advisors run as a pull request comment in CI and inside magus diff --impact locally.

Conventions

This page uses none of the site's convention markers. The full set is on the conventions page.