Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/kernel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
40 changes: 39 additions & 1 deletion packages/kernel/src/Capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,16 @@ const PatternAction = Schema.Literals(
] as const
)

const patternActions: ReadonlySet<string> = 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.
Expand All @@ -160,6 +170,34 @@ export class CapabilityPattern extends Schema.Class<CapabilityPattern>("@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<CapabilityPattern> => {
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 =>
Expand Down Expand Up @@ -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("/**")) {
Expand Down
14 changes: 13 additions & 1 deletion packages/kernel/src/CapabilitySet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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.
*
Expand Down
10 changes: 10 additions & 0 deletions packages/kernel/test/Capability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Capability.Action>(
"fs:read",
Expand Down Expand Up @@ -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],
Expand Down
17 changes: 17 additions & 0 deletions packages/kernel/test/CapabilitySet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -235,6 +251,7 @@ describe("CapabilitySet", () => {
it("exports no authority-widening API", () => {
expect(Object.keys(CapabilitySets).sort()).toEqual([
"allows",
"allowsPattern",
"attenuate",
"current",
"equals",
Expand Down
Loading