[patch] MLAI-1310 - Keep the governed hook one simple command so Cursor delivers the payload - #86
Conversation
|
Note on an earlier revision of this description: it framed the defect as a regression introduced by |
a4488a0 to
7d75ace
Compare
…ers the payload Skill governance has never functioned on Cursor in any published version. It entered main already broken, in 4a92bc3 (#81): hooks.json carried no governance hooks before that commit, and every commit since has had the defect. Both governed surfaces allowed every skill without ever contacting the governance service, silently and at exit 0. On macOS and Linux Cursor does not write the event to the hook process's stdin. It base64s the JSON into the command string and pipes it in from a pipeline the spawned shell builds itself, while closing the child's own stdin: workbench.desktop.main.js R = `printf %s '${b64}' | base64 -d | ${command}` extensionHostProcess.js stdio: [ pipeStdin ? "pipe" : "ignore", "pipe", "pipe" ] Our command began `_JFAG_NOW=$(date +%s 2>/dev/null); …`, and that top-level `;` terminates Cursor's pipeline: base64 -d piped into a bare assignment that reads nothing, and npx ran as a separate command inheriting the shell's stdin — /dev/null. agent-guard read 0 bytes, could not classify the event, and rendered its no-opinion allow, which is indistinguishable from "this prompt was not a skill invocation". Computing the deadline inside a command substitution scopes the `;` and keeps the hook one simple command, so it stays the tail of Cursor's pipeline. The intent of the defensive clock read is preserved, not reverted: verified under /bin/sh, /bin/zsh and /bin/bash that a governed skill blocks and that the deadline still degrades to EMPTY when date(1) cannot be read. The validator could not catch this because it delivered the payload the way Claude Code does — on the shell's stdin — so all 34 checks passed against a hook that delivered nothing. runHook now reproduces Cursor's wrapper exactly, with fd 0 as /dev/null, which makes the existing stdin assertions load-bearing. Two checks are added: a static one rejecting any top-level `;`, `&&` or `||` in a governed command, and a behavioural one asserting the payload survives under every shell Cursor might pick. Against the previous hooks.json the suite now fails 8 ways. Verified against the published plugin restored byte-for-byte, swapping only these two command lines: before the fix, governed runs reached the governance service zero times; after it, a request arrives within the hook's own window.
7d75ace to
9d03a99
Compare
| "beforeSubmitPrompt": [ | ||
| { | ||
| "command": "_JFAG_NOW=$(date +%s 2>/dev/null); npm_config_fetch_retries=0 npm_config_fetch_timeout=10000 JF_AGENT_GUARD_ENFORCE_DEADLINE=\"${_JFAG_NOW:+$((_JFAG_NOW + 25))}\" npx --yes --registry \"${JFROG_AGENT_GUARD_REPO:-https://releases.jfrog.io/artifactory/api/npm/coding-agents-npm/}\" @jfrog/agent-guard --enforce-skill --client cursor", | ||
| "command": "npm_config_fetch_retries=0 npm_config_fetch_timeout=10000 JF_AGENT_GUARD_ENFORCE_DEADLINE=\"$(_JFAG_NOW=$(date +%s 2>/dev/null); echo ${_JFAG_NOW:+$((_JFAG_NOW + 25))})\" npx --yes --registry \"${JFROG_AGENT_GUARD_REPO:-https://releases.jfrog.io/artifactory/api/npm/coding-agents-npm/}\" @jfrog/agent-guard --enforce-skill --client cursor", |
There was a problem hiding this comment.
The pull request title contains [patch].
plugins/jfrog/.cursor-plugin/plugin.json still has version 0.6.3.
Tag v0.6.3 already exists.
The release job fails.
Cursor skips an unchanged version.
| "preToolUse": [ | ||
| { | ||
| "command": "_JFAG_NOW=$(date +%s 2>/dev/null); npm_config_fetch_retries=0 npm_config_fetch_timeout=10000 JF_AGENT_GUARD_ENFORCE_DEADLINE=\"${_JFAG_NOW:+$((_JFAG_NOW + 25))}\" npx --yes --prefer-offline --registry \"${JFROG_AGENT_GUARD_REPO:-https://releases.jfrog.io/artifactory/api/npm/coding-agents-npm/}\" @jfrog/agent-guard --enforce-skill --client cursor", | ||
| "command": "npm_config_fetch_retries=0 npm_config_fetch_timeout=10000 JF_AGENT_GUARD_ENFORCE_DEADLINE=\"$(_JFAG_NOW=$(date +%s 2>/dev/null); echo ${_JFAG_NOW:+$((_JFAG_NOW + 25))})\" npx --yes --prefer-offline --registry \"${JFROG_AGENT_GUARD_REPO:-https://releases.jfrog.io/artifactory/api/npm/coding-agents-npm/}\" @jfrog/agent-guard --enforce-skill --client cursor", |
There was a problem hiding this comment.
.cursor-plugin/marketplace.json still has metadata.version 0.6.3.
Set it to the same value as plugins/jfrog/.cursor-plugin/plugin.json.
| "command": "_JFAG_NOW=$(date +%s 2>/dev/null); npm_config_fetch_retries=0 npm_config_fetch_timeout=10000 JF_AGENT_GUARD_ENFORCE_DEADLINE=\"${_JFAG_NOW:+$((_JFAG_NOW + 25))}\" npx --yes --registry \"${JFROG_AGENT_GUARD_REPO:-https://releases.jfrog.io/artifactory/api/npm/coding-agents-npm/}\" @jfrog/agent-guard --enforce-skill --client cursor", | ||
| "command": "npm_config_fetch_retries=0 npm_config_fetch_timeout=10000 JF_AGENT_GUARD_ENFORCE_DEADLINE=\"$(_JFAG_NOW=$(date +%s 2>/dev/null); echo ${_JFAG_NOW:+$((_JFAG_NOW + 25))})\" npx --yes --registry \"${JFROG_AGENT_GUARD_REPO:-https://releases.jfrog.io/artifactory/api/npm/coding-agents-npm/}\" @jfrog/agent-guard --enforce-skill --client cursor", | ||
| "timeout": 30, | ||
| "failClosed": false |
There was a problem hiding this comment.
failClosed stays false.
A crash, a timeout, or an npx failure still allows the skill.
| @@ -59,6 +59,12 @@ const nodeDir = path.join(sandbox, "node-only"); | |||
| mkdirSync(nodeDir, { recursive: true }); | |||
| symlinkSync(process.execPath, path.join(nodeDir, "node")); | |||
| symlinkSync("/bin/date", path.join(nodeDir, "date")); | |||
There was a problem hiding this comment.
The comment above still describes epoch 25 when date is absent.
The hook now sends an empty deadline in that case.
| // clock read needs a ';' to separate it from the expansion, and at top level that ';' would | ||
| // sever the payload pipeline Cursor wraps around this command (MLAI-1310). Scoping it here | ||
| // keeps the hook one simple command while preserving the degrade-to-empty behaviour. | ||
| assert(h.command.includes( |
There was a problem hiding this comment.
This static check requires the empty-degrade form.
The behavioural deadline check still runs with date on PATH.
No check omits date to confirm an empty JF_AGENT_GUARD_ENFORCE_DEADLINE.
| @@ -211,10 +237,17 @@ for (const step of GOVERNED_STEPS) { | |||
| const h = entriesFor(step)[0]; | |||
| assert(/_JFAG_NOW=\$\(date \+%s 2>\/dev\/null\);/.test(h.command), | |||
There was a problem hiding this comment.
This regex matches the broken top-level form and the scoped form inside $( ).
topLevelOf already rejects a top-level ;.
| // --------------------------------------------------------------------------- | ||
| // Strip every $(…) / $((…)) group, leaving only the command's TOP-LEVEL text. A ';' inside a | ||
| // substitution is scoped and harmless; one outside it is not. | ||
| const topLevelOf = (s) => { |
There was a problem hiding this comment.
topLevelOf tracks only $(…).
It does not track subshells, backticks, or quotes.
A wrapper such as ( cmd; npx ) can fail this check.
|
|
||
| // Cursor spawns `process.env.SHELL || "/bin/sh"` with -c, so the command must survive whichever | ||
| // shell the user happens to have. Shells absent from the runner are skipped rather than failed. | ||
| check("the payload survives Cursor's pipeline under every shell Cursor may pick", () => { |
There was a problem hiding this comment.
This check uses /bin/sh, /bin/bash, and /bin/zsh only for payload bytes.
Deadline, deny, and fail-open checks always use /bin/sh.
| const result = spawnSync(shell, ["-c", wrapped], { | ||
| // fd 0 is /dev/null, exactly as Cursor leaves it. A hook that only works because the | ||
| // harness fed it stdin does not work in Cursor. | ||
| stdio: ["ignore", "pipe", "pipe"], |
There was a problem hiding this comment.
runHook now models the macOS and Linux pipeline with fd 0 closed.
Cursor on Windows writes real stdin.
CI uses ubuntu-latest only.
YoniMelki
left a comment
There was a problem hiding this comment.
Bump plugins/jfrog/.cursor-plugin/plugin.json and .cursor-plugin/marketplace.json before you merge with [patch].
See the inline comments.
Fixes MLAI-1310.
Cursor skill governance has never functioned in any published plugin version. Both governed surfaces —
beforeSubmitPromptandpreToolUse— allowed every skill without ever contacting the governance service, silently, at exit 0.What was wrong
On macOS and Linux, Cursor does not write the event to the hook process's stdin. It base64s the JSON into the command string, pipes it in from a pipeline the spawned shell builds itself, and closes the child's own stdin:
Sis ourcommand, concatenated raw — so the hook command is the tail of a pipeline. Ours began_JFAG_NOW=$(date +%s 2>/dev/null); …, and after concatenation the shell sees:That top-level
;terminates the pipeline.base64 -dpipes into a bare assignment that reads nothing, andnpxruns as a separate command inheriting the shell's stdin —/dev/null. agent-guard reads 0 bytes, cannot classify the event, and renders its no-opinion allow, which is byte-identical to "this prompt was not a skill invocation".When it entered
enforce-skill;8e79b6e39bfd5fc4cb1d11ba4b974a92bc3hooks.jsononmainhad no governance hooks before4a92bc3(enforce-skilloccurrences 0 → 2 there), and the squash collapsed the branch — so8e79b6e's working form never reachedmain.c4cb1d1is not an ancestor ofmain. This is not a regression from a working release; the feature shipped dead.The only blocks anywhere in the Cursor hook logs came from a local dev install — visible as
Running script in directory: …/plugins/local/jfrog— on the pre-c4cb1d1branch state.The fix
Compute the deadline inside a command substitution, so the
;it needs is scoped and the hook stays one simple command:JF_AGENT_GUARD_ENFORCE_DEADLINE="$(_JFAG_NOW=$(date +%s 2>/dev/null); echo ${_JFAG_NOW:+$((_JFAG_NOW + 25))})" npx --yes …The intent of the defensive clock read is preserved, not reverted: verified under
/bin/sh,/bin/zshand/bin/bashthat a governed skill blocks, and that the deadline still degrades to empty whendate(1)cannot be read (agent-guard ignores an empty deadline; a garbage epoch would floor its budget at 500ms and block everything).No wrapper script, no new file in the enforcement path.
Evidence — the semicolon isolated
All four runs use Cursor's exact wrapper, a real
$SHELL, and fd 0 =/dev/null:_JFAG_NOW=$(date +%s); … agent-guard(shipped){"continue":true}— governance never ran;{"continue":false, …blocking…}agent-guard, no assignments{"continue":false, …blocking…}true; agent-guard{"continue":true}A no-op
true;is sufficient to break it, which rules out the deadline,date, and the environment.Verified against the real published plugin
Install restored byte-for-byte to the published commit, skills invoked in Cursor, then only these two command lines swapped for the fixed form and Cursor restarted:
;)Before the fix, a skill known to be policy-blocked was allowed — the same skill that blocked from the local dev install on the pre-
c4cb1d1state.Triage note: duration and stderr are both weak signals on their own. A dead hook still costs ~1.2–1.5s because the prompt hook revalidates npx regardless, and agent-guard only emits
diag()when something fails — a clean successful allow is silent. The reliable tell is whether a request reaches the service.Why CI missed it, and what now catches it
validate-skill-governance.mjsexecuted the real command string, but delivered the payload the way Claude Code does — on the shell's stdin:It never reproduced Cursor's concatenation, so all 34 checks — including "forwards stdin verbatim" — passed against a hook that delivered nothing.
runHooknow reproduces Cursor's wrapper exactly, withfd 0as/dev/null. That single change makes every existing stdin assertion load-bearing. Two checks are added:;,&&or||in a governed command, which fails with the offending text rather than a mystery allow;$SHELL || /bin/sh), skipping shells absent from the runner.base64is symlinked into the sandbox PATH for the same reasondatealready was — Cursor's delivery needs it, and without it every stdin assertion would fail for an unrelated reason.Against the previous
hooks.jsonthe suite now fails 8 ways, including both behavioural stdin checks:On this branch: all checks pass, plus
validate-templateand 23 unit tests.The general rule worth keeping: a hook validator must invoke the command exactly as the target client delivers it, per client.
Not the cause
Two reports attributed this to a stdin-reading line in
~/.zshrcdraining the hook pipe. That is not what happens: Cursor invokes$SHELL -c, so a non-interactive zsh sources~/.zshenv, never~/.zshrc— and it is moot anyway, because fd 0 is/dev/null, so there is nothing for an rc file to steal. Those reports reproduced the failure by piping the payload into the shell's own stdin, a coupling Cursor never creates. The bash-wrapper workaround they proposed does work, but becausebash ./x.shis a single command, not because bash skips~/.zshrc.Scope
Cursor only.
claude-pluginnever had the;(still the inline form) and delivers on real stdin.vscode-pluginhas the;but delivers on real stdin too (stdin.write(JSON.stringify(…)),stdio:["pipe"…]), so it works — it is hardened to the same shape in its pending PR, since these strings are kept identical across the three plugins and a copy from there to here would reintroduce this.Follow-up (not this PR)
agent-guard renders an empty or unparseable payload as a clean allow with no diagnostic at all, which is what kept this invisible. Worth a
diag()on zero bytes plus an opt-inJF_AGENT_GUARD_ENFORCE_FAIL_CLOSED; the default should stay allow, matching the deliberate fail-open posture.