Skip to content

[minor] MLAI-1288 - Enforce Cursor skill governance through agent-guard - #81

Merged
shmuelqwak merged 6 commits into
mainfrom
feature/MLAI-1288-cursor-governance-via-agent-guard
Aug 30, 2026
Merged

[minor] MLAI-1288 - Enforce Cursor skill governance through agent-guard#81
shmuelqwak merged 6 commits into
mainfrom
feature/MLAI-1288-cursor-governance-via-agent-guard

Conversation

@shmuelqwak

Copy link
Copy Markdown
Collaborator

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

npm_config_fetch_retries=0 npm_config_fetch_timeout=10000 \
JF_AGENT_GUARD_ENFORCE_DEADLINE="$(($(date +%s) + 25))" \
npx --yes --prefer-offline --registry "${JFROG_AGENT_GUARD_REPO:-…}" \
  @jfrog/agent-guard --enforce-skill --client cursor

Byte-identical on both governed surfaces — beforeSubmitPrompt (unmatched, so every submission) and preToolUse (matcher Read, since Cursor has no Skill tool).

before after
|| exit 2 present removed
--waiver-helper passed removed — gone from agent-guard's flag table
version ${JFROG_AGENT_GUARD_VERSION:+@…} unpinned → always latest
deadline none absolute, computed fresh, no inheritable fallback
fetch bounds none retries=0, timeout=10000
cache none --prefer-offline, refreshed by a sessionStart pre-warm
timeout / failClosed 20 / true 30 / false

Infrastructure failure now fails OPEN

|| exit 2 is gone and failClosed is false. npx missing, 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. failClosed was 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 jf servers, no setup.json, no env):

beforeSubmitPrompt -> {"continue":true}   exit=0
preToolUse         -> {}                  exit=0
agent-guard: could not resolve JFrog credentials, so this skill is NOT governed

Both are the correct Cursor allow shapes — explicit JSON, never silence.

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. 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.mjs to 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-guard bare. Proven empirically, not just asserted — with JFROG_AGENT_GUARD_VERSION=9.9.9 exported, 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:

no project, no credentials
v1.10.0 {"continue":false} — "no project is set"
v1.11.0 {"continue":true} — allowed

1.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-offline on the governed hooks is only safe if something refreshes the npx cache. Without it, a machine can serve a stale agent-guard indefinitely — this was hit in real testing: a cached 1.10.0 kept being used while 1.11.0 was latest, silently reinstating the bug above. It read as a governance verdict, not as a stale binary.

Cursor's hook schema has no async field, so the pre-warm detaches itself:

( npx --yes --registry "${JFROG_AGENT_GUARD_REPO:-…}" @jfrog/agent-guard --version >/dev/null 2>&1 & ) ; exit 0

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 explicit exit 0 keeps a warm failure from ever failing session start.

Validation

scripts/validate-skill-governance.mjs was 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 from hooks.json against a stub npx.

Behavioural checks run against both surfaces with surface-shaped payloads, rather than testing preToolUse and inheriting the result. Notable checks:

  • npm_config_fetch_timeout is asserted by value (<= 10000), so an edit back to npm's 300000 default fails rather than passing a presence check.
  • The deadline check executes the command and reads what the child receives, including that a stale inherited JF_AGENT_GUARD_ENFORCE_DEADLINE does not survive.
  • The pre-warm must exist, must not use --prefer-offline, must not pin a version, must detach, and must end in exit 0.
  • The package-resolution sessionStart hook is located by content, never by index — that list is shared and other features append to it; it has already moved once.

Not verified

  • Windows. Cursor's hook schema has no shell field, so the command runs in the platform's default shell. It is POSIX (${VAR:-default}, $(( ))), so on cmd/PowerShell it cannot run and governed actions are allowed unchecked. Stated in the README; unchanged from before this PR, which was equally POSIX-only.
  • Warm per-call cost. The hook spawns a shell, npx and the guard on every prompt submission and every Read, with no throttle and no verdict cache. --prefer-offline is what makes that affordable, but the warm cost has not been measured.
  • Cold start allows. With no pre-warm completed and 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.180.6.0, bumped in both plugins/jfrog/.cursor-plugin/plugin.json and .cursor-plugin/marketplace.json as validate-version.yml requires.

shmuelqwak and others added 3 commits August 27, 2026 14:54
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>
@shmuelqwak
shmuelqwak requested a review from a team as a code owner August 27, 2026 15:01
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 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.

The inline comments list the required changes.

Comment thread README.md Outdated
Comment thread .github/workflows/validate-skill-governance.yml
Comment thread README.md Outdated
Comment thread plugins/jfrog/hooks/hooks.json
Comment thread scripts/validate-skill-governance.mjs
Comment thread .github/workflows/validate-skill-governance.yml
Comment thread plugins/jfrog/hooks/hooks.json Outdated
shmuelqwak and others added 2 commits August 28, 2026 14:38
… 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>
@shmuelqwak
shmuelqwak merged commit 4a92bc3 into main Aug 30, 2026
4 checks passed
@shmuelqwak
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.
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