Skip to content

[patch] MLAI-1310 - Keep the governed hook one simple command so Cursor delivers the payload - #86

Open
shmuelqwak wants to merge 1 commit into
mainfrom
bugfix/MLAI-1310-cursor-hook-payload-pipeline
Open

[patch] MLAI-1310 - Keep the governed hook one simple command so Cursor delivers the payload#86
shmuelqwak wants to merge 1 commit into
mainfrom
bugfix/MLAI-1310-cursor-hook-payload-pipeline

Conversation

@shmuelqwak

@shmuelqwak shmuelqwak commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes MLAI-1310.

Cursor skill governance has never functioned in any published plugin version. Both governed surfaces — beforeSubmitPrompt and preToolUse — 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:

// workbench.desktop.main.js
E === 1 ? (L = JSON.stringify(s), R = S)                       // Windows: real stdin
        : (L = undefined, R = `printf %s '${I}' | base64 -d | ${S}`)

// extensionHostProcess.js
const m = i?.pipeStdin ?? false;                               // hooks never pass pipeStdin
pc(process.env.SHELL || "/bin/sh", ["-c", h],
   { stdio: [ m ? "pipe" : "ignore", "pipe", "pipe" ] }, )    // fd 0 = /dev/null

S is our command, 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:

printf %s '<b64>' | base64 -d | _JFAG_NOW=$(date +%s 2>/dev/null); npm_config_… npx … --enforce-skill

That top-level ; terminates the pipeline. base64 -d pipes into a bare assignment that reads nothing, and npx runs 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

commit enforce-skill top-level ;
8e79b6e 2 0 governance introduced — worked
39bfd5f 2 0 worked
c4cb1d1 2 2 ← authored the defect ("Degrade to no deadline when the clock cannot be read")
1ba4b97 2 2
4a92bc3 2 2 ← shipped it (squash-merge of #81)

hooks.json on main had no governance hooks before 4a92bc3 (enforce-skill occurrences 0 → 2 there), and the squash collapsed the branch — so 8e79b6e's working form never reached main. c4cb1d1 is not an ancestor of main. 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-c4cb1d1 branch 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/zsh and /bin/bash that a governed skill blocks, and that the deadline still degrades to empty when date(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:

hook command result
_JFAG_NOW=$(date +%s); … agent-guard (shipped) {"continue":true} — governance never ran
inline deadline, no ; {"continue":false, …blocking…}
bare 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:

hook duration governance service
before (;) ~1450ms no request across 8 governed runs
after (fixed) 5400ms request received, inside the hook's own window

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-c4cb1d1 state.

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.mjs executed the real command string, but delivered the payload the way Claude Code does — on the shell's stdin:

spawnSync(SH, ["-c", command], { input: Buffer.from(payload),})

It never reproduced Cursor's concatenation, so all 34 checks — including "forwards stdin verbatim" — passed against a hook that delivered nothing.

runHook now reproduces Cursor's wrapper exactly, with fd 0 as /dev/null. That single change makes every existing stdin assertion load-bearing. Two checks are added:

  • a static one rejecting any top-level ;, && or || in a governed command, which fails with the offending text rather than a mystery allow;
  • a behavioural one asserting the payload survives under every shell Cursor might pick ($SHELL || /bin/sh), skipping shells absent from the runner.

base64 is symlinked into the sandbox PATH for the same reason date already was — Cursor's delivery needs it, and without it every stdin assertion would fail for an unrelated reason.

Against the previous hooks.json the suite now fails 8 ways, including both behavioural stdin checks:

FAIL beforeSubmitPrompt computes the deadline fresh, with no inheritable fallback
FAIL preToolUse computes the deadline fresh, with no inheritable fallback
FAIL no governed command has a top-level ';', '&&' or '||' (it is the tail of Cursor's pipeline)
FAIL the payload survives Cursor's pipeline under every shell Cursor may pick
FAIL beforeSubmitPrompt: forwards stdin verbatim and hands agent-guard the expected argv
FAIL beforeSubmitPrompt: hands agent-guard a deadline in the future, computed at invocation
FAIL preToolUse: forwards stdin verbatim and hands agent-guard the expected argv
FAIL preToolUse: hands agent-guard a deadline in the future, computed at invocation

On this branch: all checks pass, plus validate-template and 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 ~/.zshrc draining 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 because bash ./x.sh is a single command, not because bash skips ~/.zshrc.

Scope

Cursor only. claude-plugin never had the ; (still the inline form) and delivers on real stdin. vscode-plugin has 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-in JF_AGENT_GUARD_ENFORCE_FAIL_CLOSED; the default should stay allow, matching the deliberate fail-open posture.

@shmuelqwak
shmuelqwak requested a review from a team as a code owner September 2, 2026 10:27
@shmuelqwak

shmuelqwak commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Note on an earlier revision of this description: it framed the defect as a regression introduced by c4cb1d1. That was wrong. c4cb1d1 authored it, but the squash-merge of #81 meant the working form never reached main, so no published version has ever had functioning Cursor skill governance. The description above is corrected, and the commit message with it.

@shmuelqwak
shmuelqwak force-pushed the bugfix/MLAI-1310-cursor-hook-payload-pipeline branch from a4488a0 to 7d75ace Compare September 2, 2026 12:23
…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.
@shmuelqwak
shmuelqwak force-pushed the bugfix/MLAI-1310-cursor-hook-payload-pipeline branch from 7d75ace to 9d03a99 Compare September 2, 2026 14:36
"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",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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", () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

runHook now models the macOS and Linux pipeline with fd 0 closed.
Cursor on Windows writes real stdin.
CI uses ubuntu-latest only.

@YoniMelki YoniMelki left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bump plugins/jfrog/.cursor-plugin/plugin.json and .cursor-plugin/marketplace.json before you merge with [patch].
See the inline comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants