[minor] MLAI-1288 - Enforce Cursor skill governance through agent-guard - #81
Merged
shmuelqwak merged 6 commits intoAug 30, 2026
Merged
Conversation
Replaces the in-plugin Node governance with a direct npx call to agent-guard, and brings the Cursor wiring in line with the Claude Code plugin. Infrastructure failure now fails OPEN. `|| exit 2` is gone and `failClosed` is false: npx missing, a failed install, an unreachable registry or no configured server let the skill through. A machine that cannot run the guard is not governed by it, and refusing every skill there enforces nothing except the user's inability to work. A real policy denial still blocks — on Cursor the verdict travels as JSON with exit 0, so the payload carries it regardless of failClosed. The waiver helper is deleted. agent-guard v1.11.0 files waivers itself via --request-waiver, and --waiver-helper is no longer in its flag table, so the plugin holds no credentials and no waiver logic. Nothing may pin the version: the hook resolves `latest` so a shipped GA fix reaches users without a plugin release. The call also bounds its fetch, prefers the cache, and passes an absolute deadline computed fresh at invocation — with no inheritable fallback, since an absolute instant inherited from an earlier process pins every later invocation to the past. No sessionStart pre-warm: Cursor's hook schema has no async field, so one would stall session start. Cold start therefore allows; tracked as a follow-up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sor-governance-via-agent-guard # Conflicts: # .cursor-plugin/marketplace.json # README.md # plugins/jfrog/.cursor-plugin/plugin.json
--prefer-offline on the governed hooks is only safe if something refreshes the npx cache. Claude Code's plugin does that with an async SessionStart pre-warm; dropping it here left a machine able to serve a stale binary indefinitely. Measured: a cached 1.10.0 kept being used while 1.11.0 was latest, which silently reinstated a bug 1.11.0 had fixed — 1.10.0 decides "no project" locally and BLOCKS, where 1.11.0 refers it to the service and fails open on unresolvable credentials. The stale cache read as a governance verdict. Cursor's hook schema has no async field, so the command detaches itself: a subshell background plus an explicit exit 0, which returns in 0s and never fails session start. It deliberately omits --prefer-offline, since hitting the registry is its only purpose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An absent date(1) made $(($(date +%s) + 25)) expand to 25 — an epoch in 1970 — which floors the enforcement budget at 500ms and blocks every skill, reported as a governance timeout. Passing empty instead is safe: agent-guard ignores an empty deadline and uses its own default budget. failClosed: false does not cover this. The block arrives as a deny in the JSON payload, which agent-guard produced deliberately; failClosed only governs a hook that fails on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
YoniMelki
requested changes
Aug 28, 2026
YoniMelki
left a comment
Collaborator
There was a problem hiding this comment.
The inline comments list the required changes.
… validator The README claimed the hook always exits 0 and that running out of time allows. Both are wrong: when the Agent Guard's own budget expires it writes a refusal and exits 2, so it BLOCKS. What allows is Cursor killing the hook at its own timeout — a different clock, and the longer of the two. The three cases are now stated separately, and a paragraph that had been duplicated verbatim is gone. The capabilities table promised more coverage than the Skills governance section admits; it now names the same two entry points and the same gap. validate-skill-governance.yml is pull-request only, so nothing checked the merge commit a release is cut from. release.yml now runs the script directly, matching what it already does for validate-template.mjs and what CONTRIBUTING.md requires. Adds validator cases for output that is not valid JSON and for empty stdout with exit 0, and watches modules/** so re-added governance code cannot slip past the trigger. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sionStart The sessionStart pre-warm does not work. Measured under a real Cursor session, twice: Cursor kills the hook's process group, and nothing outruns it — not a detached subshell, not sh -mc, not a perl setsid escape. It was dead code that implied a freshness guarantee. That left --prefer-offline with nothing refreshing it, which freezes agent-guard at whatever version was first fetched. That is the failure that made a cached 1.10.0 keep serving verdicts a released 1.11.0 had already fixed. The refresh now happens on a hook Cursor actually waits for. beforeSubmitPrompt drops --prefer-offline and revalidates against the registry; preToolUse keeps it and reads what that left behind. Measured warm: 1091ms revalidating, 324ms from cache. A prompt fires once per turn, a Read dozens of times, so the cost lands where it is invisible and the hot path stays fast. Session start pays nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
YoniMelki
approved these changes
Aug 28, 2026
shmuelqwak
deleted the
feature/MLAI-1288-cursor-governance-via-agent-guard
branch
August 30, 2026 05:18
AsalaZr
added a commit
that referenced
this pull request
Aug 30, 2026
Resolve version conflict: bump 0.6.1 (patch on top of 0.6.0 from #81) Co-authored-by: Cursor <cursoragent@cursor.com>
shmuelqwak
added a commit
that referenced
this pull request
Sep 2, 2026
…ers the payload Skill governance has never functioned on Cursor in any published version. It entered main already broken, in 4a92bc3 (#81, 2026-08-30): 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. Evidence from every Cursor hook log on the reporting machine: 384 runs with the `;` produced 0 blocks and 0 agent-guard stderr; 145 runs without it produced 8 blocks, and those all came from a local dev install predating the merge. Re-confirmed against the published plugin restored byte-for-byte: 8 governed runs, 0 stderr, and 0 requests reaching governance-manager-webapp.
shmuelqwak
added a commit
that referenced
this pull request
Sep 2, 2026
…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
added a commit
that referenced
this pull request
Sep 3, 2026
…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
added a commit
that referenced
this pull request
Sep 3, 2026
…or delivers the payload (#86) Fixes [MLAI-1310](https://jfrog-int.atlassian.net/browse/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: ```js // 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: ```sh 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**: ```sh 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: ```js 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Moves Cursor skill governance out of in-plugin Node and into
agent-guard, and brings the Cursor wiring in line with the Claude Code plugin — where the behaviour genuinely should match, and deliberately not where Cursor differs.What the hook does now
Byte-identical on both governed surfaces —
beforeSubmitPrompt(unmatched, so every submission) andpreToolUse(matcherRead, since Cursor has noSkilltool).|| exit 2--waiver-helper${JFROG_AGENT_GUARD_VERSION:+@…}latestretries=0,timeout=10000--prefer-offline, refreshed by a sessionStart pre-warmfailClosedtruefalseInfrastructure failure now fails OPEN
|| exit 2is gone andfailClosedisfalse.npxmissing, a failed install, an unreachable registry, or no configured JFrog server all let the skill through. A machine that cannot get a verdict is not governed by it, and refusing every skill there enforces nothing except the user's inability to work.A real policy denial still blocks: on Cursor the verdict travels as JSON on stdout with exit 0, so the payload carries it regardless of
failClosed.failClosedwas only ever a crash net, never how a block is signalled.This is where Cursor deliberately diverges from Claude Code. The Claude plugin blocks when the guard reaches the check but cannot finish it in time; here that case allows, because a killed or failed hook is not a block under
failClosed: false. The README states the divergence rather than papering over it.Verified against a genuinely unconfigured machine (0
jfservers, nosetup.json, no env):Both are the correct Cursor allow shapes — explicit JSON, never silence.
The waiver helper is deleted
agent-guardv1.11.0 files waivers itself via--request-waiver, and--waiver-helperis no longer in its flag table.plugins/jfrog/scripts/governance/is removed entirely, so the plugin holds no credentials and no waiver logic.Two earlier commits on this branch had rewritten
request-waiver.mjsto the Unified Policy Phase II payload. That work is obsolete under the new split, so the branch was recommitted without it.Nothing may pin the version
All three invocations name
@jfrog/agent-guardbare. Proven empirically, not just asserted — withJFROG_AGENT_GUARD_VERSION=9.9.9exported, argv still carries bare@jfrog/agent-guard. A shipped GA fix therefore reaches users without a plugin release.Minimum required: agent-guard ≥ 1.11.0. This matters more than it looks. On 1.10.0 the same event blocks:
{"continue":false}— "no project is set"{"continue":true}— allowed1.10.0 decided "no project" locally; 1.11.0 refers it to the service and fails open on unresolvable credentials.
Why sessionStart pre-warms the cache
--prefer-offlineon the governed hooks is only safe if something refreshes the npx cache. Without it, a machine can serve a staleagent-guardindefinitely — this was hit in real testing: a cached 1.10.0 kept being used while 1.11.0 waslatest, silently reinstating the bug above. It read as a governance verdict, not as a stale binary.Cursor's hook schema has no
asyncfield, so the pre-warm detaches itself:Measured: returns in 0s, and the detached fetch lands the latest version in the cache. It deliberately omits
--prefer-offline, since hitting the registry is its only purpose, and the explicitexit 0keeps a warm failure from ever failing session start.Validation
scripts/validate-skill-governance.mjswas rewritten — its old assertions demanded the exact things this PR removes (must end in "|| exit 2",must pass the waiver helper,failClosed === true). 34 checks, all passing, asserting the inverted contract and executing the real command string fromhooks.jsonagainst a stubnpx.Behavioural checks run against both surfaces with surface-shaped payloads, rather than testing
preToolUseand inheriting the result. Notable checks:npm_config_fetch_timeoutis asserted by value (<= 10000), so an edit back to npm's 300000 default fails rather than passing a presence check.JF_AGENT_GUARD_ENFORCE_DEADLINEdoes not survive.--prefer-offline, must not pin a version, must detach, and must end inexit 0.Not verified
shellfield, so the command runs in the platform's default shell. It is POSIX (${VAR:-default},$(( ))), so oncmd/PowerShell it cannot run and governed actions are allowed unchecked. Stated in the README; unchanged from before this PR, which was equally POSIX-only.npxand the guard on every prompt submission and everyRead, with no throttle and no verdict cache.--prefer-offlineis what makes that affordable, but the warm cost has not been measured.failClosed: false, the first governed action on a cold machine can be allowed. The detached pre-warm narrows the window but does not close it.Release
[minor]—0.5.18→0.6.0, bumped in bothplugins/jfrog/.cursor-plugin/plugin.jsonand.cursor-plugin/marketplace.jsonasvalidate-version.ymlrequires.