Skip to content

feat(skills): self-service skill submission with admin review - #36677

Closed
yassin-berriai wants to merge 1 commit into
litellm_internal_stagingfrom
litellm_skill_submission_admin_review
Closed

feat(skills): self-service skill submission with admin review#36677
yassin-berriai wants to merge 1 commit into
litellm_internal_stagingfrom
litellm_skill_submission_admin_review

Conversation

@yassin-berriai

@yassin-berriai yassin-berriai commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Only admins could add skills, users could not submit
  • Registering a skill published it instantly, with no review
  • Nothing tied an approval to the reviewed content

How it solves it:

  • Non-admin submissions land pending and unpublished
  • Admins approve or reject, with notes
  • Approval carries a fingerprint of the reviewed manifest
  • Only approved skills reach the public hub

User Flow

Before: a developer who wants their team's skill on the gateway cannot add it themselves, so an admin has to do every submission by hand

  1. They open http://localhost:4000/ui/?page=skills and see the skills table with no way to add one, since the button is admin-only
  2. They call POST http://localhost:4000/claude-code/plugins with their key and get 401 "Only proxy admin allowed", so the skill never lands
  3. An admin has to register it for them, and whatever the admin registers is published to http://localhost:4000/claude-code/marketplace.json immediately with no review step

After: the same developer submits it themselves, and it stays private until an admin approves the exact content they read

  1. They open http://localhost:4000/ui/?page=skills, click "+ Submit Skill", fill in the name and GitHub source, and see "Skill submitted for administrator review"
  2. Their row shows a "Pending Review" badge, and GET http://localhost:4000/claude-code/plugins with their key returns the skill with "approval_status": "pending_review" and "enabled": false
  3. GET http://localhost:4000/claude-code/marketplace.json and GET http://localhost:4000/public/skill_hub do not list it, so claude plugin install cannot pick it up yet
  4. An admin opens the same page, clicks "Awaiting review (1)", and clicks Approve on the row, or Reject and types a reason
  5. On approve the badge flips to "Active" and the skill now appears in http://localhost:4000/claude-code/marketplace.json and http://localhost:4000/public/skill_hub
  6. On reject the badge reads "Rejected", the submitter sees the reviewer's note, and the skill stays absent from both public lists
  7. If the submitter edits the skill between the admin reading it and the admin approving it, the approve comes back 409 saying the skill is no longer the submission that was reviewed, the skill stays unpublished, and the admin reviews the new content instead

Another user who has nothing to do with the submission cannot see a pending or rejected skill at all: GET http://localhost:4000/claude-code/plugins omits it and GET http://localhost:4000/claude-code/plugins/{name} returns 404 for them, while the submitter and admins can read it

Relevant issues

Linear ticket

Resolves LIT-5465

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Screenshots / Proof of Fix

Captured against a live proxy on a real Postgres 16, driving the real HTTP routes. Both legs use the same database and the same requests, and each leg mints its own internal_user key and refuses to run if that key comes back empty, so a refusal below can never be an artifact of a missing key

Before, at b0626cad8c (unmodified litellm_internal_staging), proxy on :4465

$ curl -sS -w "HTTP %{http_code}\n" -X POST localhost:4465/claude-code/plugins \
    -H "Authorization: Bearer $USER_KEY" -H "Content-Type: application/json" \
    -d '{"name":"alice-team-skill","source":{"source":"github","repo":"acme/alice-team-skill"},"version":"0.9.0"}'
HTTP 401
{"error":{"message":"Authentication Error, Only proxy admin can be used to generate, delete, update info for new keys/users/teams. Route=/claude-code/plugins. Your role=internal_user. Your user_id=dev-al*ce","type":"auth_error","param":"None","code":"401"}}

# so an admin registers it by hand, and it is published the moment it is created
$ curl -sS -X POST localhost:4465/claude-code/plugins -H "Authorization: Bearer sk-1234" ... 
{"status":"success","action":"created","plugin":{"name":"alice-team-skill","enabled":true}}

$ curl -s localhost:4465/claude-code/marketplace.json | jq -c '[.plugins[].name]'
["alice-team-skill"]
$ curl -s localhost:4465/public/skill_hub | jq -c '[.plugins[].name]'
["alice-team-skill"]

# there is no approval state to read, and no route to gate it
$ curl -s localhost:4465/claude-code/plugins/alice-team-skill -H "Authorization: Bearer sk-1234" \
    | jq -c '{name, enabled, approval_status, manifest_fingerprint}'
{"name":"alice-team-skill","enabled":true,"approval_status":null,"manifest_fingerprint":null}
$ curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST localhost:4465/claude-code/plugins/alice-team-skill/approve -H "Authorization: Bearer sk-1234"
HTTP 404

After, at 7f02a85d29, proxy on :4466

# 1. the developer submits their own skill, and it lands pending and unpublished
$ curl -sS -w "HTTP %{http_code}\n" -X POST localhost:4466/claude-code/plugins \
    -H "Authorization: Bearer $USER_KEY" -H "Content-Type: application/json" \
    -d '{"name":"alice-team-skill","source":{"source":"github","repo":"acme/alice-team-skill"},"version":"0.9.0"}'
HTTP 200
{"action":"submitted_for_review","plugin":{"name":"alice-team-skill","enabled":false,"approval_status":"pending_review"}}

# 2. nothing public serves it, and an unrelated internal user cannot even read it
marketplace.json: []
public skill hub: []
bob's list:       []
bob reading it directly: HTTP 404

# 3. publishing it without a review is refused
HTTP 409 {"error":"Skill 'alice-team-skill' is awaiting review. Approve it via POST /claude-code/plugins/alice-team-skill/approve"}

# 4. the admin opens the review queue and reads the skill
{"name":"alice-team-skill","created_by":"dev-alice","approval_status":"pending_review","manifest_fingerprint":"7e9be72826c4a092ffff83b8740d43fc397765480d357940c1ba4c244c59a9b5"}

# 5. while the admin is reading it, the submitter swaps the source out
$ curl -X PUT localhost:4466/claude-code/plugins/alice-team-skill -H "Authorization: Bearer $USER_KEY" \
    -d '{"source":{"source":"github","repo":"acme/swapped-in-after-review"},"version":"0.9.1"}'
submitter edit: HTTP 200

# 6. the admin's approval of the content they actually read is refused
$ curl -X POST localhost:4466/claude-code/plugins/alice-team-skill/approve -H "Authorization: Bearer sk-1234" \
    -d '{"reviewed_fingerprint": "7e9be72826c4a092ffff83b8740d43fc397765480d357940c1ba4c244c59a9b5"}'
HTTP 409 {"error":"Skill 'alice-team-skill' is no longer the submission that was reviewed. Read it again and review the current content."}

# 7. so the swapped-in source is still published nowhere
marketplace.json: []
public skill hub: []
{"enabled":false,"approval_status":"pending_review","source":{"source":"github","repo":"acme/swapped-in-after-review"}}

# 8. the admin re-reads the changed skill and approves that instead
fingerprint now: 2fdaeca87f7ceb900737d7c20661c300e8ca299d6e8ed07d8239421d794bd86d
HTTP 200 {"approval_status":"active","enabled":true,"reviewed_by":"default_user_id","reviewed_at":"2026-08-12T17:30:42.411148+00:00"}

# 9. now, and only now, it is installable
marketplace.json: [{"name":"alice-team-skill","source":{"source":"github","repo":"acme/swapped-in-after-review"}}]
public skill hub: ["alice-team-skill"]

# 10. a second submission, rejected with a note the submitter reads
HTTP 200 {"approval_status":"rejected","enabled":false,"review_notes":"point the source at the reviewed internal fork"}
alice sees:       {"approval_status":"rejected","review_notes":"point the source at the reviewed internal fork"}
public skill hub: ["alice-team-skill"]

Steps 5 to 7 are the case the fingerprint exists for. Without it the approve in step 6 succeeds and publishes acme/swapped-in-after-review, a source no administrator ever looked at

Both legs were captured at the commits named above. The head is now 5cb1e405e2, which is 7f02a85d29 carried forward onto 160548d40b with the review fixes described below. The rebase onto 160548d40b changed the feature diff in exactly two lines, both of them absorbing a typing refactor staging landed in 6b5249bcce: public_skill_hub now reaches the table through staging's _plugin_table helper rather than the repository directly, and that helper's find_many protocol widened from Mapping[str, bool] to Mapping[str, object], since the published-skill filter carries an approval_status string alongside enabled. Nothing else moved. The review fixes do change three refusal paths, so the numbers above still describe steps 1 to 10, and the new behaviour is covered by the tests named below rather than by this capture

Settling the compare-and-set question raised in review, against a real Postgres 16 and a prisma client generated from this branch's schema

$ python cas_proof.py
CAS miss  -> value=0  type=int  has .count=False
           `miss == 0` evaluates to True
           row after miss: approval_status='pending_review' enabled=False
CAS hit   -> value=1  type=int
           row after hit:  approval_status='active' enabled=True

The miss is the exact state a submitter edit landing between the admin's read and the approve write produces: the where no longer matches, nothing is written, and the skill stays unpublished

UI screenshots for the submit form, the pending badge, the review queue, and the approve and reject dialogs are below

Type

🆕 New Feature

Caveats (if any)

  • Rows created before this default to active
  • Editing an approved skill sends it back to review
  • Rejecting is not fingerprint-bound, it never publishes
  • An approved skill cannot be rejected, only disabled
  • Docs land in a separate litellm-docs PR

ui-unit-tests was red on 12 failures in memory, workflows and guardrails-monitor, none of which this PR's feature touches. They failed identically on unmodified b0626cad8c, and this PR only ran those files at all because vitest related pulls in most of the dashboard whenever the generated schema.d.ts moves. Staging has since fixed all three, so the one-line workflows fix this PR was carrying dropped out of the rebase and the diff is now the feature alone

Review notes

Cursor Bugbot has filed five findings across two rounds. Four were real and are fixed in the current head 55a80add53; one rests on a premise that does not hold for this client, and the run above is why. The first round landed on a8dd69d9db, the second on 5cb1e405e2 after the first round's fixes

Update and delete leak hidden skills (Medium), fixed. get_plugin 404s a skill the caller cannot see, but update_plugin and delete_plugin 403'd once the row existed, so the status code told any internal user that a name was taken by someone else's pending submission. Both now refuse through the same _caller_can_see check that get_plugin uses, so a hidden skill answers exactly as an absent one does. A published skill is visible to everyone, so a non-owner touching one still gets 403 and that boundary is pinned by its own test. enable, disable, approve and reject gate on is_proxy_admin before any read, so a non-admin never reaches their lookup

The repo does both elsewhere, so worth naming the precedent rather than asserting a convention: the container ownership checks return a bare 403 (container_endpoints/ownership.py:228 and :341), while the nearest neighbour by concept, the skills handler, folds the two cases together on purpose and says why in a comment, "Same 'not found' message for both 'missing' and 'cross-tenant' so callers can't enumerate skill IDs they don't own" (llms/litellm_proxy/skills/handler.py:156). Same resource_ownership module, same feature area, and the same shape as get_plugin. The decisive argument is internal though: without this change a caller 404s on read and 403s on update for one row

Orphaned submissions lack owner stamp (Medium), fixed. register_plugin stamped created_by from user_id or get_primary_resource_owner_scope(...) and never rejected when both were absent, leaving a pending row its own submitter could not list, read, update or withdraw. resource_ownership.py already documents that contract: callers that depend on a primary scope "must surface that as a hard error rather than fall back to a shared sentinel". Submissions with no attributable owner are now refused, so created_by is non-null for every row in the review queue

The reachable path is narrower than the finding implies, and worth stating so the severity is judged on evidence. Master-key auth populates both api_key and user_id and is proxy admin anyway, and a virtual key always carries token (user_api_key_auth.py:2070-2072), so neither can produce this. What does is no-auth dev mode: with master_key unset, user_api_key_auth.py:1476-1487 returns a UserAPIKeyAuth carrying only user_role=INTERNAL_USER, and a request with no Authorization header leaves api_key as "". That caller is identity-less, is not an admin, and /claude-code/plugins admits internal users, so it lands on the orphan branch. So it needs a proxy with no authentication configured at all, which is exactly the deployment where an unwithdrawable pending row is least likely to be noticed

CAS check ignores BatchPayload count (High), not reproducible. The finding says update_many returns a BatchPayload whose .count field makes reviewed_rows == 0 dead code. That is the TypeScript client's shape. prisma-client-py 0.11.0 defines no BatchPayload at all: grep -rn BatchPayload over the installed package returns nothing, and all 70 generated update_many methods in prisma/actions.py annotate -> int, with no other return annotation among them, so this is the generator's contract rather than a per-table accident. The one this code calls is LiteLLM_ClaudeCodePluginTableActions.update_many at prisma/actions.py:64454, whose body returns int(resp["data"]["result"]["count"]). Nor does the config-sync wrapper reshape it, and by a stronger route than I first wrote here: litellm_claudecodeplugintable is absent from _CONFIG_SYNCED_TABLE_NAMES, so wrap_table_actions_for_config_sync returns the actions object unwrapped and no wrapper is in the path at all. The capture above drives it end to end rather than arguing from the source: a non-matching where returns 0, an int with no .count, == 0 is True, and the row stays pending_review with enabled=False. To be precise about what the suite does and does not show: the compare-and-set branch is covered, by test_approval_write_does_not_publish_an_edit_that_lands_after_the_fingerprint_check, which lands a submitter edit inside the window so the pre-check passes and only the write can catch it. What that test cannot settle is the return type, because its fake table returns len(matched), an int by construction, so it would stay green under a client that returned an object. That is the gap the run above closes, and it is why this was worth answering against a real database rather than from the source

Register leaks hidden skill names (Medium), not fixable in scope, and the paragraph above used to overclaim. The second round pointed out that register_plugin still answers 409 for a name held by someone else's hidden submission, so POST remains a probe the read and mutate routes no longer are. That is correct, and an earlier version of this description called get, update and delete "the complete set", which was wrong. Register is a fourth probe

It cannot be closed without changing what a skill name is. name is String @unique in schema.prisma, globally, so a create against a taken name cannot both refuse and stay silent: any response distinguishable from success is an oracle, and the only indistinguishable one would be to claim the submission was accepted and drop it. Scoping submissions per owner instead, @@unique([name, created_by]), would genuinely close it, but the published namespace has to stay unique because marketplace.json keys on the name and claude plugin install <name> resolves by it, so that is a schema change reaching every by-name route rather than something to slip into this PR

What remains is narrower than the read-route leak was. _name_conflict_error names neither the owner nor the review state, only that the name is taken, and a non-admin GET /claude-code/plugins still lists active plus their own, so there is no way to enumerate submissions. It is a guess-confirm oracle on a name the caller already has in hand, which is the same property any globally unique namespace has at signup. Happy to take the composite-key design as its own ticket if that trade is worth making

Reject revokes published skills (Medium), fixed. _record_review accepted a reject from any state other than rejected, including active. Because _caller_can_see keys on approval_status, rejecting an approved skill did more than unpublish it: it hid the skill from every non-owner, where disable leaves it visible and merely takes it out of the public lists. That is a takedown wearing a review step's name, and both docstrings describe reject as applying to a submitted skill. An approved skill now refuses the reject with a 400 that points at disable, and test_rejecting_an_approved_skill_is_refused_and_leaves_it_published pins it by asserting the skill is still active, still enabled, still carries no review note and still appears in marketplace.json. Removing the guard fails that test

No privilege boundary moved here, since an admin can still delete the skill outright. The reason to fix it is that reject and disable had silently different blast radii, and only one of them was documented

Five new tests cover this, and each was checked by mutation: restoring the pre-fix shape in update_plugin, in delete_plugin, dropping the identity guard, and dropping the approved-skill reject guard each fail their own test, and hiding published skills too fails the 403 boundary test. The three carried over from the first round were re-run at the current head rather than assumed to survive the rebase

Two older veria findings are deliberately not addressed here, since both are about the shape of the feature rather than a defect in it, and both want an API change this PR's scope does not carry. Submissions are unbounded (no per-owner quota, no field length caps, no pagination on the list route), and review metadata (created_by, reviewed_by, review_notes) is returned to any internal user for an active skill. Happy to take either in this PR or a follow-up

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

@yassin-berriai
yassin-berriai requested a review from a team August 12, 2026 17:26
@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds self-service skill submission with administrator review and content-bound approval

  • Stores non-admin submissions as pending and unpublished
  • Adds approve and reject endpoints with review metadata
  • Restricts hidden submissions to administrators and their submitters
  • Updates the dashboard with submission and review workflows
  • Publishes only active, enabled skills to public catalogs

Confidence Score: 4/5

The concurrent reject transition must be made atomic before merging because it can hide a newly approved skill

Rejection validates the previously read state but updates only by name, so a concurrent approval between the read and write can be overwritten with rejected and disabled state

Files Needing Attention: litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py

Important Files Changed

Filename Overview
litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py Implements ownership-aware submission and review transitions, but rejection is not atomically restricted to pending rows
litellm-proxy-extras/litellm_proxy_extras/migrations/20260812000000_add_claudecodeplugin_approval_status/migration.sql Adds approval and review metadata with active defaults for existing rows
litellm/proxy/public_endpoints/public_endpoints.py Filters the public skill hub to active, enabled skills
ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx Adds self-service submission and administrator review controls to the skills dashboard

Reviews (5): Last reviewed commit: "feat(skills): self-service skill submiss..." | Re-trigger Greptile

@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.18182% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...s/claude_code_endpoints/claude_code_marketplace.py 97.59% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_skill_submission_admin_review (55a80ad) with litellm_internal_staging (c1310de)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (69b0296) during the generation of this report, so c1310de was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai please review the current head a8dd69d. It rebases the reviewed commit onto staging and fixes one unrelated dashboard test

Comment thread litellm/proxy/_types.py
"/search_tools/ui/available_providers",
# Self-service skill submission. Approve/reject stay admin-only.
"/claude-code/marketplace.json",
"/claude-code/plugins",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Low: Unbounded persistent skill submissions

An internal user can create an arbitrary number of uniquely named pending skills, while the request fields have no length limits, submissions have no per-owner quota, and the list endpoint has no pagination. An attacker can continuously grow this table and force plugin-list requests to load and serialize all of their records; add bounded field lengths, a per-owner pending-submission limit, and pagination before exposing the create route.

enabled=p.enabled,
approval_status=_as_approval_status(p.approval_status),
manifest_fingerprint=_manifest_fingerprint(p.manifest_json),
review_notes=p.review_notes,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Low: Review and owner metadata disclosed across users

The non-admin filter includes every active skill, and these fields are then returned unconditionally. An unrelated internal user can enumerate submitter and reviewer IDs, along with any approval notes, for all approved skills; only include review metadata for proxy admins and callers matching the resource owner.

@veria-ai

veria-ai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

PR overview

This pull request adds self-service skill submission, admin review, and marketplace listing for approved skills.

The submission flow currently allows internal users to create unlimited, potentially oversized pending records and trigger unpaginated listings, creating persistent storage and response-amplification risk. Approved-skill listings also disclose submitter, reviewer, and approval metadata to unrelated internal users. Both issues remain open, so access controls and abuse limits should be tightened before rollout.

Open issues (2)

Fixed/addressed: 0 · PR risk: 6/10

@ryan-crabbe-berri

Copy link
Copy Markdown
Contributor

bugbot run

},
)
if reviewed_rows == 0:
raise _stale_review_error(plugin_name)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

CAS check ignores BatchPayload count

High Severity

update_many from prisma-client-py returns a BatchPayload with a .count field, but the compare-and-set path treats the return value as an integer and compares it to 0. When a submitter edit lands between the read and the write, the update matches no rows, yet the == 0 check never fires, so approve still returns success while the skill stays unpublished.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a8dd69d. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Checked against real Postgres: prisma-client-py update_many returns a plain int (0 on miss), no BatchPayload. Evidence in the PR description

@yassin-berriai
yassin-berriai force-pushed the litellm_skill_submission_admin_review branch from a8dd69d to ca1a4e5 Compare August 13, 2026 18:31
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai please review the current head ca1a4e5. It fixes two bugbot findings; reasoning and the third finding's rebuttal are in the PR description

@yassin-berriai
yassin-berriai force-pushed the litellm_skill_submission_admin_review branch from ca1a4e5 to 5cb1e40 Compare August 13, 2026 20:43
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai please review the current head 5cb1e40. It rebases onto staging, absorbing a typing refactor in public_endpoints. The feature diff is otherwise unchanged

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

bugbot run

"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
"created_by": user_api_key_dict.user_id,
"created_by": owner_scope,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Register leaks hidden skill names

Medium Severity

register_plugin returns 409 whenever a name is taken, including for pending or rejected skills the caller cannot see. get_plugin, update_plugin, and delete_plugin intentionally answer 404 for those rows so names cannot be enumerated, but POST still confirms a hidden submission exists under that name.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5cb1e40. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct, and my earlier 'complete set' claim was wrong. Skill names are globally unique, so a create cannot hide a collision. Reasoning in the description

@yassin-berriai
yassin-berriai force-pushed the litellm_skill_submission_admin_review branch from 5cb1e40 to 55a80ad Compare August 13, 2026 20:56
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai please review the current head 55a80ad. It restricts reject to unapproved skills and answers the register name-collision finding in the description

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment on lines +731 to +734
where={ # mutable-ok: prisma query arguments must be plain dicts
"name": plugin_name,
**({"manifest_json": existing.manifest_json} if publishes else {}),
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Concurrent rejection overwrites approval

If approval and rejection overlap, rejection updates by name only and can overwrite the active state, hiding the newly approved skill

Suggested change
where={ # mutable-ok: prisma query arguments must be plain dicts
"name": plugin_name,
**({"manifest_json": existing.manifest_json} if publishes else {}),
},
where={ # mutable-ok: prisma query arguments must be plain dicts
"name": plugin_name,
**(
{"manifest_json": existing.manifest_json}
if publishes
else {"approval_status": SKILL_PENDING_REVIEW}
),
},

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 55a80ad. Configure here.

"enabled": publishes,
"updated_at": reviewed_at,
},
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reject race demotes approved skills

High Severity

_record_review blocks rejecting an already-approved skill on the pre-read, but the reject update_many only matches on name. If an approve lands after that read, the reject write still succeeds and sets approval_status to rejected with enabled false, unpublishing the skill and hiding it from non-owners—the same takedown the sequential guard was meant to prevent.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 55a80ad. Configure here.

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.

4 participants