diff --git a/packages/kernel/README.md b/packages/kernel/README.md index d71ac33..295bef7 100644 --- a/packages/kernel/README.md +++ b/packages/kernel/README.md @@ -15,8 +15,8 @@ The root exports these namespaces, also available from matching | Namespace | Public exports | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Capability` | `Action`, exact `Capability`, `PatternAction`, and `CapabilityPattern`; `make`, `format`, `formatPattern`, `parse`, `matches`, and `subsumes`; `EffectTier`, `TierOptions`, `tierOf`, and `requiresIdempotencyKey`. | -| `CapabilitySet` | `CapabilitySet`; `fromPatterns`, empty authority `none`, `allows`, `intersect`, `equals`, ambient `current`, and monotone `attenuate`. No widening constructor or unrestricted value is public. | +| `Capability` | `Action`, exact `Capability`, `PatternAction`, and `CapabilityPattern`; `make`, `format`, `formatPattern`, `parse`, `parsePattern`, `matches`, and `subsumes`; `EffectTier`, `TierOptions`, `tierOf`, and `requiresIdempotencyKey`. | +| `CapabilitySet` | `CapabilitySet`; `fromPatterns`, empty authority `none`, `allows`, `allowsPattern`, `intersect`, `equals`, ambient `current`, and monotone `attenuate`. No widening constructor or unrestricted value is public. | | `Permission` | `PermissionRequired`, `PermissionDenied`, `GrantStoreErrorCode`, and `GrantStoreError`; policy `RuleEffect`, `Rule`, and `evaluate`; constructors `permissionRequired` and `permissionDenied`. | | `GrantEvent` | `GrantTier`, `GrantScope`, `OnceGrant`, `RememberedGrant`, `RunGrant`, `DeniedGrant`, `EnvelopeGrant`, `GrantEventSchema`, `GrantEvent`, `decode`, and `encode`. | | `GrantStore` | `PendingRequest`, `Resolution`, `EnvelopeGrantOptions`, `Persist`, and `MakeOptions`; `Service` / `GrantStore` operations `check`, `reply`, `list`, and `grantEnvelope`; `isValidGrantPattern`, `isValidEnvelopePattern`, `make`, `layer`, allow-all `makeNoop`, and `layerNoop`. | diff --git a/packages/kernel/src/Capability.ts b/packages/kernel/src/Capability.ts index 2d94772..3312c9c 100644 --- a/packages/kernel/src/Capability.ts +++ b/packages/kernel/src/Capability.ts @@ -148,6 +148,16 @@ const PatternAction = Schema.Literals( ] as const ) +const patternActions: ReadonlySet = new Set([ + ...actions, + "fs:*", + "net:*", + "model:*", + "proc:*", + "jj:*", + "*" +]) + /** * An action and resource glob used to grant or deny a family of capabilities. * Resource globs are slash-normalized and matched against the whole resource. @@ -160,6 +170,34 @@ export class CapabilityPattern extends Schema.Class("@smither resource: Schema.String }) {} +/** + * Parses a declared capability requirement as a pattern. The conservative + * registry shorthand `*` means every action over every resource. + * + * @since 0.1.0 + * @category parsing + */ +export const parsePattern = (input: string): Option.Option => { + if (input === "*") { + return Option.some(new CapabilityPattern({ action: "*", resource: "**" })) + } + const components = input.split(":") + const namespace = components[0] + const operation = components[1] + if (namespace === undefined || operation === undefined || components.length < 3) { + return Option.none() + } + const action = `${namespace}:${operation}` + return patternActions.has(action) + ? Option.some( + new CapabilityPattern({ + action: action as PatternAction, + resource: components.slice(2).join(":") + }) + ) + : Option.none() +} + const normalizeSlashes = (value: string): string => value.replaceAll("\\", "/") const matchesAction = (pattern: PatternAction, action: Action): boolean => @@ -198,7 +236,7 @@ const actionSubsumes = (left: PatternAction, right: PatternAction): boolean => { const resourceSubsumes = (left: string, right: string): boolean => { const normalizedLeft = normalizeSlashes(left) const normalizedRight = normalizeSlashes(right) - if (normalizedLeft === normalizedRight || normalizedLeft === "**") { + if (normalizedLeft === normalizedRight || normalizedLeft === "*" || normalizedLeft === "**") { return true } if (!normalizedLeft.endsWith("/**")) { diff --git a/packages/kernel/src/CapabilitySet.ts b/packages/kernel/src/CapabilitySet.ts index 5dbaa8e..8f06955 100644 --- a/packages/kernel/src/CapabilitySet.ts +++ b/packages/kernel/src/CapabilitySet.ts @@ -9,7 +9,7 @@ * @since 0.1.0 */ import { Context, Effect } from "effect" -import { type Capability, type CapabilityPattern, matches } from "./Capability.ts" +import { type Capability, type CapabilityPattern, matches, subsumes } from "./Capability.ts" const CapabilitySetTypeId: unique symbol = Symbol.for("@smithers/kernel/CapabilitySet") @@ -125,6 +125,18 @@ export const allows = ( capability: Capability ): boolean => set.groups.every((group) => group.some((pattern) => matches(pattern, capability))) +/** + * Tests whether the set provably contains every capability selected by one + * declared requirement pattern. + * + * @category predicates + * @since 0.1.0 + */ +export const allowsPattern = ( + set: CapabilitySet, + required: CapabilityPattern +): boolean => set.groups.every((group) => group.some((pattern) => subsumes(pattern, required))) + /** * Intersects two authorities without synthesizing or simplifying globs. * diff --git a/packages/kernel/test/Capability.test.ts b/packages/kernel/test/Capability.test.ts index f7b98b8..acf7c41 100644 --- a/packages/kernel/test/Capability.test.ts +++ b/packages/kernel/test/Capability.test.ts @@ -18,6 +18,15 @@ describe("Capability", () => { expect(Option.isNone(Capability.parse("fs:read"))).toBe(true) }) + it("parses declared requirement patterns and the conservative wildcard", () => { + expect(Option.getOrNull(Capability.parsePattern("fs:read:src/**"))).toEqual( + pattern("fs:read", "src/**") + ) + expect(Option.getOrNull(Capability.parsePattern("*"))).toEqual(pattern("*", "**")) + expect(Option.isNone(Capability.parsePattern("unknown:action:**"))).toBe(true) + expect(Option.isNone(Capability.parsePattern("fs:read"))).toBe(true) + }) + it("round trips formatted capabilities", () => { const action = FastCheck.constantFrom( "fs:read", @@ -62,6 +71,7 @@ describe("Capability", () => { [pattern("fs:read", "src/a.ts"), pattern("fs:read", "src/a.ts"), true], [pattern("fs:*", "src/**"), pattern("fs:read", "src/nested/a.ts"), true], [pattern("*", "**"), pattern("jj:*", "repository"), true], + [pattern("*", "*"), pattern("fs:read", "/workspace/**"), true], [pattern("jj:*", "repository/**"), pattern("jj:diff", "repository/one"), true], [pattern("fs:read", "src/**"), pattern("fs:write", "src/a.ts"), false], [pattern("fs:read", "src/*"), pattern("fs:read", "src/a.ts"), false], diff --git a/packages/kernel/test/CapabilitySet.test.ts b/packages/kernel/test/CapabilitySet.test.ts index a2b46a1..3ee99c0 100644 --- a/packages/kernel/test/CapabilitySet.test.ts +++ b/packages/kernel/test/CapabilitySet.test.ts @@ -123,6 +123,22 @@ describe("CapabilitySet", () => { )).toBe(false) }) + it("proves a declared requirement is contained by every intersected group", () => { + const set = CapabilitySets.intersect( + CapabilitySets.fromPatterns([new CapabilityPattern({ action: "fs:*", resource: "/workspace/**" })]), + CapabilitySets.fromPatterns([new CapabilityPattern({ action: "fs:read", resource: "/workspace/src/**" })]) + ) + + expect(CapabilitySets.allowsPattern( + set, + new CapabilityPattern({ action: "fs:read", resource: "/workspace/src/a.ts" }) + )).toBe(true) + expect(CapabilitySets.allowsPattern( + set, + new CapabilityPattern({ action: "fs:read", resource: "/workspace/**" }) + )).toBe(false) + }) + it("intersect is commutative", () => { check([setArbitrary, setArbitrary], (left, right) => CapabilitySets.equals( @@ -235,6 +251,7 @@ describe("CapabilitySet", () => { it("exports no authority-widening API", () => { expect(Object.keys(CapabilitySets).sort()).toEqual([ "allows", + "allowsPattern", "attenuate", "current", "equals",