validate-permission-group-item
GitHub用于端到端审计企业权限组项的安全验证技能,检查注册表、模式、解析器及强制实施点,确保权限控制真正生效而非仅结构存在。
Trigger Scenarios
Install
npx skills add simstudioai/sim --skill validate-permission-group-item -g -y
SKILL.md
Frontmatter
{
"name": "validate-permission-group-item",
"description": "Audit an existing enterprise permission-group item end-to-end — registry entry, schemas, type, defaults, tolerant parser, admin UI, capability rule, enforcement site, and tests — proving the gate actually refuses rather than assuming it. Use when checking a key in `PERMISSION_GROUP_FIELDS` or a capability in `CAPABILITY_RULES`.",
"argument-hint": "<config-key-or-capability-id>"
}
Validate Permission Group Item Skill
The question is not "does this key exist in the right places" — the registry makes most of that compiler-enforced. It is:
If an organization admin sets this, what refuses, and can I make that refusal happen?
Twelve keys once shipped with a checkbox, a hint, and no server check. Every one would have passed a structural audit. Assume nothing enforces until you have found the throw.
add-permission-group-item owns the procedure and the rationale for every invariant named below. Read it for why; this skill is the checklist. Its "Read the system first" list is the same one — start there.
Step 1: Registry entry (lib/permission-groups/fields.ts)
Record the builder, the enforcement, and the position.
- Default permissive? The builders hardcode
false/null/[], so the risk is a name that inverts the meaning — anallowXboolean. The checkbox renderschecked={!editingConfig[feature.configKey]}(ticked = allowed), so a positively-named boolean renders backwards. - Position stable? Declaration order is the wire order and
fields.test.tspins it with a key-order contract test. Ifgit log -pshows the key was ever moved rather than appended, that shipped as an editor dirty-check regression. - Phrasing accurate? An allowlist's
{ limited, empty }and a denylist's string are read bygetActivePermissionGroupRestrictionsinfeatures.tsand surface to users through the Copilot workspace VFS and the enterprise platform context. Confirmemptysays "none allowed", not "unrestricted". - Does the
hinttell the truth? Highest-value read in this step. A'capability'key refuses at the API, so a hint saying it hides a tab, module, or nav item "from the sidebar" is a lie an admin acts on — they believe they are tidying chrome while withholding a module. The same string is reused as the prose for an active restriction, where "hide" is simply false. Any surviving "Hide the …" hint on a'capability'key is a finding, not a nit; checklabelandcategorythe same way (a "Sidebar" or "Settings Tabs" section makes the claim structurally).
Step 2: Schemas, type, defaults, parser
All derived by collectFieldProperty from the same registry. Do not hand-verify them. Verify nothing bypasses the derivation:
grep -rn "<configKey>" apps/sim --include='*.ts' --include='*.tsx' \
| grep -vE 'lib/permission-groups/(fields|resolve\.server|config-scope\.server)\.ts'
Only the registry and the resolvers are excluded, so capabilities.ts stays in the output — its CAPABILITY_RULES entry and deniedBy are the authoritative reads this step exists to check. Every hit should be a rule's deniedBy, an enforcement site, a UI binding, or a test. A route restating the key, a client re-deriving a default, or a second coercion path is a leak. Specifically:
z.array(...).catch(...)anywhere on this key's path — whole-value tolerant, so one bad member discards every good one, and on an allowlist thenullfallback means unrestricted. That is fail-open.tolerantArrayfilters element-wise. Rank a regression here with the enforcement findings.?? []applied to an allowlist — collapses "allows everything" into "allows nothing".- A hand-rolled comparison against an integration allowlist.
allowedIntegrationsand the deployment'sALLOWED_INTEGRATIONSare written independently, so one namesslackwhere the other namesslack_v2; anything folding only case intersects them to nothing and hides an integration both allow. Both halves must canonicalize throughintegration-allowlist.ts(intersectAccessControlAllowlists/toAccessControlAllowlist/resolveAccessControlBlockType) before intersecting, with the checked type resolved the same way. That module readsblock-successors.generated.tsthroughObject.hasOwn— a bare bracket lookup answers an admin-suppliedconstructorwith an inherited function and 500s every path reading that group;check:block-successorscatches the map going stale. - Any config read not from
parsePermissionGroupConfigor aresolvePermissionGroupConfigcaller.
Two structural guards must still be present:
parsePermissionGroupConfigstill testsArray.isArray(config).typeof [] === 'object', the column isjsonbso a row genuinely can hold[], andz.object().parse([])throws — the guard is what returns defaults instead of a 500.tolerantArraycarries the mirror image.CAPABILITY_RULESstill usessatisfies, not an annotation. An annotation collapsesStaticPermissionGroupCapabilitytonever, silently disabling the type system around capabilities with nothing wrong at runtime.AssertsStaticCapabilityResolvescatches it; any weakening is a top-tier finding.
Confirm the assertions at the bottom of fields.ts still name a field of this kind (AssertsAllowlistStaysPrecise, AssertsDenylistStaysPrecise, AssertsRestrictionStaysPrecise, AssertsAuthTypesStayPrecise, AssertsParserReturnsTheConfig) — a zod generic degrading to unknown is invisible at runtime and quietly loses every call site's narrowing.
Step 3: Admin UI (ee/access-control/components/group-detail.tsx)
- Boolean: appears automatically via
PLATFORM_FEATURES. Confirm itscategoryis inPLATFORM_CATEGORY_ORDER; an unlisted one renders after every ordered section. - Nested allowlist / denylist (one that qualifies a platform-feature boolean): renders nothing unless it is in the
featureExtrasmap — keyed by the parent boolean's feature id, not the config key. No picker there means no admin can ever set it. Report it. Top-level lists —allowedIntegrations,allowedModelProviders,deniedModels,deniedTools— are not infeatureExtrasand must not be reported for it; they render from the dedicated Providers and Blocks sections, so check them there. - For an allowlist picker, check both behaviors: refuses an empty selection (
if (values.length === 0) return) and collapses a full one back tonull(otherwise the allowlist freezes at today's members). A denylist picker must do neither: clearing every entry is how an admin denies nothing, and a full selection is a real state that denies everything. - Check the parent is the right one (
allowedKnowledgeConnectorsunderhide-knowledge-base, notdisable-knowledge-base-creation).
Step 4: Capability rule
A 'capability' key must appear in some rule's configKeys — the audit asserts this (D) and the converse (E): a key declared 'executor' or 'ui-only' that a rule reads is flagged, so a key cannot gain enforcement while staying documented as weaker. Then check what the audit cannot:
configKeyslists every keydeniedByreads. The audit parses it textually and never reads the closure; a key read but unlisted is invisible to D and E.kindis right. A rule needing a request value must be'parameterized'— and a parameterized rule named on an operation cannot have run in production (defineWorkspaceOperationthrows at definition time), so something else is wrong.- A narrower capability subsumes the broader one it replaced. An operation carries exactly one capability. Precedent:
knowledge.create/knowledge.uploadboth readhideKnowledgeBaseTab, without which a group withholding the whole module could still create a KB through the API. Checkgit logfor a re-pointedcapability:and verify the narrower rule grew the broader key in the same commit. detailCodematches the remedy —FORBIDDEN_DETAIL_CODESis closed over remedies, not causes; otherwisePERMISSION_GROUP_CAPABILITY_BLOCKED. Any code in use needs an entry inFORBIDDEN_DETAIL_CODE_DESCRIPTIONS, a compile-time gate that also publishes the OpenAPI 403 text.describereads correctly as the subject of"<describe> is not available under your organization's permission group"— a singular noun or gerund agreeing with "is". Exactly two functions build that sentence, both defined incapabilities.ts(refuseCapabilitythrows it,capabilityRefusalreturns it); any call site writing it out is a drift finding.
Step 5: Prove the enforcement — do not assume it
The step the skill exists for. Find the actual refusal, name file and line, and say what a caller sees.
grep -rn "'<capability-id>'" apps/sim --include='*.ts' --include='*.tsx'
grep -rn "permission-group-enforced: <capability-id>" apps/sim
The second grep misses a gate whose annotation sits in a TSDoc block above the enclosing statement — read the surrounding function.
Classify into exactly one of:
- Declared on operations. The funnel enforces in
requireCurrentHumanAccess→requireCapability. Verify the set is complete: enumerate every route and tool reaching the same behavior. One declaringcapability: 'none'is the hole. - Asserted at a call site with a
// permission-group-enforced: <id> — <reason>annotation. Verify it goes throughcapability-assertions.ts(assertWorkspaceCapability,isWorkspaceCapabilityWithheld,isOrganizationCapabilityWithheld,capabilityDeniedBy), throughisCapabilityWithheldForUser(lib/permission-groups/user-scope.server.ts— workspace group first, else the organization's default, for a user-level act that may or may not name a workspace; outsidecapability-assertions.tson purpose because it reads org membership through the billing graph, a guarded root ofcheck:application-graph;app/api/cli/auth/approve/route.tsis the shape), or a directCAPABILITY_RULES['<id>'].deniedBy(...)rather than readingconfig.disableXinline, and that it raises throughrefuseCapability/ renderscapabilityRefusal(cap)rather than building its ownForbiddenOperationErrorwith a hand-written message — the easy half to miss, because the decision looks right. Use-case shape:validatePublicFileSharing,validateChatDeployAuth(ee/access-control/utils/permission-check.ts),assertConnectorTypeAllowed(lib/knowledge/application/connectors.ts). Raw-route shape:app/api/logs/stats/route.ts,app/api/table/[tableId]/export/route.ts. A raw route should render throughcapabilityRefusalResponse(lib/permission-groups/capability-response.ts), which readsdetails.codeoff the rule — a hand-rolledNextResponse.json({ error: capabilityRefusal(cap) }, { status: 403 })drops it, reporting the four specifically-coded capabilities (deploy.chat.auth_mode,file_share.publish,file_share.auth_mode,personal_api_key.use) as the generic block. Convergence is partial:grep -rln "capabilityRefusal(" apps/sim/app --include=route.tslists the raw routes that still hand-roll it (ignore*.test.ts,app/api/v1/middleware.ts,app/api/table/utils.ts, and the v2 envelope, which are not raw-route responses). A raw route you add or touch renders throughcapabilityRefusalResponse; report an untouched hand-rolled one as a finding when its capability carries a specific code, otherwise as a note. v1 is deliberately not converged on it (resolveCapabilityRefusalinapp/api/v1/middleware.ts). - Executor-gated by
assertPermissionsAllowed, per block / tool / model, matching through the shared primitives inlib/permission-groups/—block-access.ts,operation-access.ts,model-access.ts,integration-allowlist.ts— which the editor and Copilot projections read too, so a second copy of a match rule is a finding. Verify the branch throws a real error and that the id it compares against is the vocabulary the admin UI writes —deniedToolsholds blocktools.accessids verbatim, version suffix included.allowedIntegrationsis also enforced off the run, byassertSelectorIntegrationAllowed(lib/selectors/server/integration-access.ts), so an executor key's coverage is not complete until every non-run path that reaches the third party is checked too. - A field projection, not a gate.
logs.trace_spansandlogs.costwithhold fields, so the logs routes correctly declarecapability: 'none'. Single owner:lib/logs/log-projection.ts(resolveLogFieldProjection,projectExecutionData,projectCostTotal), which carries both annotations. A second implementation of the same redaction is the finding — as is a query that lets a caller filter or sort on a withheld field, which turns the projection into an oracle. - Nothing. Report as a defect: "an organization that sets this believes it applied a restriction that does not exist".
Ahead of all five: personal_api_key.use fits none of them. It withholds a principal kind across every operation — the funnel's personal_api_key branch (lib/core/application/workspace-authorization.ts) and app/api/v1/middleware.ts — so no operation declares it and disablePersonalApiKeys being absent from every capability: field is correct, not a hole.
Then make the refusal happen: write a failing case, or remove the gate (the capability: field, the deniedBy body, the assertion call) and confirm an existing test goes red. A test that still passes with the gate removed proves nothing. Restore afterward. Check the fixture's workspaceOrganizationId first: requireCapability short-circuits when it is null (lib/core/application/workspace-authorization.ts:204), so a context that leaves it unset passes either way and the existing test proves nothing even before you touch it.
For an allowlist the three states must be tested separately — null permits every member, a populated list only the named ones, [] permits none. capabilities.test.ts pins all three for knowledge.connectors; less than that elsewhere is a gap.
Who the gate runs against
Read the subject, not the nearest user id. Every capability sink must take its subject from the capabilityGoverned* helper for the identity the surface holds — capabilityGovernedPrincipalUserId for a Principal (lib/core/application), capabilityGovernedUserId for a v1 RateLimitResult or a TableAccessPrincipal, capabilityGovernedAuthUserId for a checkSessionOrInternalAuth result. Each returns null where no group governs, and null is a pass. Reading rateLimit.userId, auth.userId, subjectUserId or triggeredByUserId into a sink is the finding: for a workspace key the first is the key's creator, for an internal JWT the second is the run's actor, and the last is a billing attribution. check-capability-subject.ts audits v1 only, so every other surface is on you. Where the subject is persisted and read back later (capabilityGovernedUserId on table_run_dispatches / table_row_executions), it must be declared required as string | null — an optional field with a fallback is exactly how producers re-inherited triggeredByUserId, so a proposal to make it optional is a finding.
/api/v1authorizes inapp/api/v1/middleware.ts, not throughauthorizeWorkspaceOperation;capabilityGovernedUserId(rateLimit)branches onkeyType, never on the presence of a user id. Each route also threads a required, spelled-outV1RouteCapability.- Raw internal table routes gate
tables.useincheckAccess(app/api/table/utils.ts) via aTableAccessPrincipalunion —{ kind: 'user'; userId }or{ kind: 'workspace_api_key'; keyCreatorUserId }— so a bare id does not type-check.tableAccessPrincipal(rateLimit)is the one place v1 builds it. - The definition-time
undefinedguard ondefineWorkspaceOperationis not redundant even thoughcapabilityis required on theApplicationOperationbase type (thecapabilityfield inlib/core/application/operation.ts, not merely on the builder — which is what stops a bare-literal factory from compiling):apps/sim/tsconfig.jsonexcludes*.test.ts/*.test.tsxand the enforcement audit walks past test files, so a fixture is the one construction site no static check reads. Without it a capability-less operation defines cleanly and then throwsCannot read properties of undefinedinsidecapabilityDeniedByonly for tenants that actually have a permission group, passing CI and every personal workspace. A proposal to drop it is a finding.
Step 6: Tests
fields.test.ts— the key must be in both theinputandexpectedhalves of the'a fully populated config'fixture; that corpus is pinned so a changed row is defended rather than slipping through. The rest of the file derives fromDEFAULT_PERMISSION_GROUP_CONFIGand needs no per-key edit.capabilities.test.ts— a case for any rule with logic beyond reading one key: subsumption, allowlist three-state, auth-mode membership.features.test.ts— no edit for a boolean; a non-boolean key'slimited/emptyprose should be pinned here.config-scope.server.test.ts— the per-request memo. A gate resolving the config outsideresolvePermissionGroupConfigis a Step 2 finding, not one here.
Step 7: Run the checks
bun run check:permission-group-enforcement
bun run check:application-graph
bun run check:capability-subject
cd apps/sim && bun run type-check && bunx vitest run lib/permission-groups
All three are inside check:audits, which derives its list from the check:* scripts in package.json — a new audit is opted out deliberately. Read the output, not the exit codes. Success-line shapes (the counts must include the item under audit):
✓ permission-group enforcement: <N> operations declare a capability, <M> capabilities all enforced
✅ Application graph clean: <N> roots reach none of <M> forbidden module trees
check:capability-subject — <N> v1 files, <M> capability subjects resolved through capabilityGovernedUserId.
| Audit | What it catches |
|---|---|
check:permission-group-enforcement |
Every operation declares a capability and every capability is enforced. All-or-nothing — no migration mode exits 0 with work outstanding, so do not go looking for a pending enforcement: list |
check:application-graph |
The funnel roots (lib/core/application/index.ts, capabilities.ts, capability-assertions.ts, config-scope.server.ts) and with-route-handler.ts reach no heavy module tree at runtime (import type is erased and allowed). A gate that imports a resolver into a guarded root is a finding even if the gate is correct; past regressions surfaced only as unrelated tests failing on partial mocks |
check:capability-subject |
Every v1 capability sink takes its subject from capabilityGovernedUserId, no v1 file outside the middleware imports the permission-group modules, and at least one governed sink was found at all |
Two ways the enforcement audit passes without proving what you want:
- Vacuous parse. It reads source text with regexes, so it refuses success when the three registries parse to nothing, cross-checks rule count against capability count, reports per call any unreadable
id, fails a file that mints an operation but parses to zero declarations, and flags any exported*Operationsregistry member it read no operation from. If one fires the audit is broken, not the code — fix the parsers rather than leaving it green. (That last guard is what catches an operation minted by a factory that never calls the builder, which bypasses the required type and the audit.) - A capability declared on an operation nothing routes to. Assertion C is satisfied by the declaration alone.
The audits prove reachability, never correctness — that a capability is named, a key is read by some rule, a subject came from the right helper. Step 5 is what covers the rest.
Known gaps — recognize these, do not re-report them
Each is deliberate and documented in the code; add-permission-group-item carries the full reasoning.
- A workspace API key resolves no permission group — it authorizes as the workspace, so there is no user and
operation.capabilitydoes not apply; the same reasoning shapesTableAccessPrincipal,capabilityGovernedUserIdand the log projection. Substituting the key's creator would apply a bystander's group to every caller of a shared key and break the key when that person left. Minting a workspace key is itself capability-gated. - An executor delegation carries role but not capabilities — a delegated
executorprincipal with asim_usersubject goes throughrequireCurrentHumanRoleonly. A capability names what a person may reach; applying it to a run makes "hide Tables" a kill-switch for every workflow with a Table block. - An actorless deployment run passes through — a delegated executor principal in
mode: 'deployment'with no resolvable subject acts with the workspace's authority; denying would 403 every scheduled run, webhook and public-API call. What such a run does is still governed byassertPermissionsAllowed, which is why the four run-scoped keys carryenforcement: 'executor'. - Copilot is NOT exempt — a delegated principal with a
sim_usersubject whoseserviceIdis anything other thanexecutortakes the fullrequireCurrentHumanAccess. Copilot acts as the person. A proposal to exempt it is a finding. - Capability is checked after the role check —
NoWorkspaceAccessErroris concealed as a 404 by the v2 surface, so refusing on capability first would hand a non-member an oracle for what the organization withholds. The v1 middleware states the same ordering in its TSDoc. Not a bug. allowedEgressHostsdoes not exist — there is no network-egress allowlist. Requests for one are a feature, not missing wiring.- Nothing currently ships as
ui-only— the union member has no user; an absentui-onlykey is not a gap.
Report Format
- Kind and enforcement — as declared, and whether the declaration is true.
- The refusal — file, line, error thrown, what the caller sees (status,
detailCode, message). Or: it is a projection, and here is its single owner. Or: nothing refuses. - The subject — whose user id the gate reads, and that a workspace key reaches it ungated rather than as its creator.
- Proof — the test that fails when the gate is removed, or that no such test exists.
- Coverage gaps — routes, tools, surfaces reaching the same behavior without the gate.
- Findings, ordered: unenforced key > key-creator substituted for the acting principal > fail-open coercion (
.catch()on an array, a droppedArray.isArrayguard,CAPABILITY_RULESannotated instead ofsatisfies) > incomplete operation coverage > allowlist three-state confusion > admin copy that misstates the enforcement > duplicated projection logic > missing admin UI > missing test > cosmetic.
A hint saying "hide" for a key that 403s is not cosmetic — it is the one defect an admin acts on directly: they tick it believing they hid a link, and members lose the module. Rank it with the enforcement findings.
Version History
- 6dfb467 Current 2026-09-23 06:36


