Skip to content

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

Merged
shmuelqwak merged 2 commits into
mainfrom
bugfix/MLAI-1310-cursor-hook-payload-pipeline
Sep 3, 2026
Merged

[patch] MLAI-1310 - Keep the governed hook one simple command so Cursor delivers the payload#86
shmuelqwak merged 2 commits 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
Comment thread plugins/jfrog/hooks/hooks.json
Comment thread plugins/jfrog/hooks/hooks.json
Comment thread plugins/jfrog/hooks/hooks.json
Comment thread scripts/validate-skill-governance.mjs
Comment thread scripts/validate-skill-governance.mjs
Comment thread scripts/validate-skill-governance.mjs
Comment thread scripts/validate-skill-governance.mjs
Comment thread scripts/validate-skill-governance.mjs
Comment thread scripts/validate-skill-governance.mjs

@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.

…de path

Addresses review feedback.

The manifests stay at 0.6.3, which is already tagged, so a [patch] merge would fail
the release job's existing-tag guard before publishing anything. Both carriers move
to 0.6.4 together; they were already in sync, so this is one bump, not a mismatch.

The sandbox comment claimed a PATH without date(1) "would silently yield $(( + 25))
= 25 - an epoch in 1970". That describes the pre-c4cb1d1 inline form. The command
this repo has shipped since #81 carries the ${_JFAG_NOW:+…} guard and degrades to an
EMPTY deadline instead, which agent-guard ignores in favour of its own budget. The
comment has been wrong since it was written; it sits in the block this change edits.

The degrade itself was asserted only in text: every behavioural run kept date(1) on
PATH, isolate mode included, so the guard was never executed. That is the same class
of gap this PR exists to close - the suite asserted the payload was forwarded while
the hook forwarded nothing - so the last text-only assertion is closed here. A second
sandbox PATH without date(1) drives the real command and asserts the deadline is
EMPTY and the payload still arrives.

The check is load-bearing, not decorative: swapping the command back to the inline
$(($(date +%s) + 25)) form fails it on both surfaces, because that form yields 25
rather than empty, which would floor agent-guard's budget at 500ms and block every
skill.

41 checks pass; validate-template and the 23 unit tests are unaffected.
@shmuelqwak

Copy link
Copy Markdown
Collaborator Author

Thanks — three of these were right and are fixed in 46027fd. Verdicts and evidence for the rest below.

Fixed

Manifest versions. Both carriers were at 0.6.3, which is already tagged (v0.6.3e9a6154), so a [patch] merge would have hit release.yml's "Refuse to re-release an existing version" guard and failed before publishing. Both now at 0.6.4. Worth noting the two files were already in sync — the validate job was green — so this was one bump rather than a plugin/marketplace mismatch.

The stale date comment. You're more right than the comment suggests: it claimed an absent date(1) "would silently yield $(( + 25)) = 25 — an epoch in 1970", which describes the pre-c4cb1d1 inline form. The command this repo has shipped since #81 carries the ${_JFAG_NOW:+…} guard and degrades to an empty deadline, which agent-guard ignores in favour of its own budget. Measured both forms with date off PATH: old → 25, current → "". The comment has been wrong since it was written.

The degrade path was never executed. Correct, and this was the most valuable comment of the nine. Every behavioural run kept date on PATH — isolate: true included, since it keeps nodeDir. So the guard was asserted textually and never run, which is precisely the class of gap that let MLAI-1310 ship: the suite asserted the payload was forwarded while the hook forwarded nothing. Closed with a second sandbox PATH that omits date, driving the real command and asserting the deadline is "" and the payload still arrives.

It is load-bearing, not decorative — swapping the command back to the inline form fails it on both surfaces:

FAIL beforeSubmitPrompt: with no date(1) on PATH, the deadline degrades to EMPTY and the payload still arrives
FAIL preToolUse: with no date(1) on PATH, the deadline degrades to EMPTY and the payload still arrives

41 checks pass; validate-template and the 23 unit tests are unaffected. CI green on 46027fd.

Declining, with evidence

failClosed: false. Deliberate, and asserted by two passing checks (an agent-guard failure fails OPEN and npx missing entirely fails OPEN). A machine that cannot run the guard is not governed by it; flipping this would refuse every skill on any machine with an unreachable registry, enforcing nothing except an inability to work. A real policy denial still blocks — it rides in the JSON, and agent-guard's own exit 2 still blocks covers the undeliverable case. Out of scope for MLAI-1310 either way.

The _JFAG_NOW regex matches both forms. True, it doesn't discriminate. But the very next assertion is an exact includes() of the full scoped string, which is a strict superset of the regex's text and does reject the old form — so the regex is redundant, not wrong. I've left it because its failure message is where the "tolerate an absent date(1)" intent is documented; happy to fold it into the includes() message if you'd rather.

topLevelOf ignores subshells, backticks and quotes. Verified by running it directly: ( date +%s; npx a ), npx a `foo; bar` and npx a "x;y" are all flagged; only npx a $(b; c) is not. Every gap you list produces a false positive, never a false negative — the check can reject an unusual-but-safe form, it cannot admit a severing one. The authoritative gate is the behavioural "payload survives Cursor's pipeline" check, which counts actual bytes under sh, bash and zsh. I'd add a line noting the check is deliberately conservative rather than make it a shell parser.

The multi-shell loop only covers the payload check. Accurate. The only shell-sensitive construct is the deadline expression, and it produces an identical now + 25 under /bin/sh, /bin/bash and /bin/zsh. Deny and fail-open depend on pipeline exit-code propagation, which is shell-invariant, and the pipeline itself is already covered under all three. Widening the loop costs 3× runtime to re-prove POSIX arithmetic.

Windows / ubuntu-latest only. The CI half is right — validate-skill-governance.yml has no matrix. But the premise isn't: Cursor on Windows does not write real stdin. It writes the payload to a temp file and rewrites the command as Get-Content -LiteralPath '<file>' -Raw | & { $input | <cmd> } under powershell, per extensionHostProcess.js. So this change can't be a Windows regression — and one simple command works under either delivery model, which is the point.

That does surface a real pre-existing problem you didn't raise, though: our command string is POSIX (${VAR:-…}, $(…), $((…))) and is not valid PowerShell, so the governed hooks likely fail outright on Windows today — on main exactly as much as on this branch. I'll file that separately rather than widen this PR; it needs someone with a Windows box to confirm the failure mode first.

@shmuelqwak
shmuelqwak merged commit b2eba5f into main Sep 3, 2026
4 checks passed
@shmuelqwak
shmuelqwak deleted the bugfix/MLAI-1310-cursor-hook-payload-pipeline branch September 3, 2026 12:43
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