compartment-mapper: narrow constraints for Policy type arguments
What is the Problem Being Solved?
This is the current definition of a Policy:
https://github.com/endojs/endo/blob/a925fac6ff3efd3f8bf7f63857f05da3dd9ccdbc/packages/compartment-mapper/src/types/policy-schema.ts#L92-L123
Here is the resources field, which is a Record containing PackagePolicy values:
https://github.com/endojs/endo/blob/a925fac6ff3efd3f8bf7f63857f05da3dd9ccdbc/packages/compartment-mapper/src/types/policy-schema.ts#L64-L90
The PackagePolicy.packages field is of type PolicyItem:
https://github.com/endojs/endo/blob/a925fac6ff3efd3f8bf7f63857f05da3dd9ccdbc/packages/compartment-mapper/src/types/policy-schema.ts#L38-L55
The type arguments (generics) in these types allow consumers to "extend" the shape of policy, which can be queried by consumer-provided code at various points in the compartment-mapping lifecycle. The type arguments do not and should not affect @endo/compartment-mapper's runtime behavior.
Note that these type arguments currently have no constraints (foreshadowing).
I'll highlight the PackagePolicyItem type argument of PackagePolicy. PackagePolicyItem affects the type of the PackagePolicy.packages field (see referenced type definition above). Here are a few examples of PackagePolicy:
// base `PackagePolicy` w/o generics
// using the `WildcardPolicy` type of `PolicyItem`
const packagePolicyA: PackagePolicy = {
packages: 'any'
};
// another base PackagePolicy w/o generics
// using the `PropertyPolicy` type of `PolicyItem`,
// which is just a `Record<string, boolean>`
const packagePolicyB: PackagePolicy = {
packages: {
nutter: true,
butter: false
}
};
// adds a new value to the union in `PolicyItem`;
// the inferred type of `CustomPackagePolicy.packages` is now
// `PolicyItem<'root'> = WildcardPolicy | PropertyPolicy | 'root'`
type CustomPackagePolicy = PackagePolicy<'root'>;
const packagePolicyC: CustomPackagePolicy = {
packages: 'root'
};
The above CustomPackagePolicy is not problematic, because the types in the PolicyItem<'root'> union are disjoint. The string literal type root does not satisfy the type string literal type any (WildcardPolicy), nor does it satisfy the type Record<string, boolean> (PropertyPolicy).
At runtime, these types are also disjoint and so a type guard could be written as:
const isCustomPackagePolicy = (value: unknown): value is CustomPackagePolicy =>
!!value &&
(value === 'any' ||
value === 'root' ||
(Object(value) === value &&
Object.entries(value).every(([k, v]) => typeof k === 'string' && typeof v === 'boolean')));
This all comes tumbling down if given a T in PolicyItem<T> which extends PropertyPolicy (Record<string, boolean>):
// `TruePropertyPolicy` extends `PropertyPolicy`!
type TruePropertyPolicy = Record<string, true>;
type AmbiguousPackagePolicy = PackagePolicy<TruePropertyPolicy>;
It is not possible to write a type guard which distinguishes between TruePropertyPolicy and PropertyPolicy since all values of a PropertyPolicy can be true.
Providing string for T in PolicyItem<T> has a similar problem; it simplifies to string | PropertyPolicy, dropping WildcardProperty entirely (though admittedly this could be avoided thru use of LiteralUnion).
Further, any T in PolicyItem<T> which is an object-like type (e.g., a Record<string, boolean>; {foo: 'bar'}) creates a conflict with the semantic meaning of PropertyPolicy! We expect the key of a PropertyPolicy to refer to the canonical name of some Compartment. Any type which violates this assumption will give us fits if we need to parse it (like in the case of Compartment Map Transforms).
Design 1
Type Changes
We need to narrow the type of T in PolicyItem<T> so that it cannot interfere with PropertyPolicy | WildcardPolicy.
// policy must be JSON-serializable, so we grab this
type JsonObject = {[key: string]: JsonValue};
type JsonArray = JsonValue[];
type JsonValue = string | number | boolean | null | JsonObject | JsonArray;
// disallow objects at root of type, but allow within arrays
type PolicyItemValue = Exclude<JsonValue, JsonObject>;
// note to self: the following does not work as expected when T is a template string
// type containing a literal, e.g., `${string}.js`
// type IsLiteralString<T extends string> = string extends T ? false : true;
type IsLiteralString<T extends string> = {} extends Record<T, never> ? false : true;
export type PolicyItem<T extends PolicyItemValue | void = void> = T extends string
? IsLiteralString<T> extends false
? never
: T | WildcardPolicy | PropertyPolicy
: T | WildcardPolicy | PropertyPolicy;
I noted that policy should be JSON-serializable, so we will need to narrow other type arguments within Policy.
Update Policy Validation
Policy validation (policy-format.js) will need to take these changes into account.
Distill PolicyItem During Enforcement
It is not absolutely required, but it'd be a good idea to strip out anything we don't expected to be in a PolicyItem just prior to enforcing policy. We should not persist the stripped policy in the CompartmentDescriptor, since it may be exposed to the consumer who may expect the original custom shape.
Design 2
Change PolicyItem<T> so that any T is a value in PropertyPolicy. For example, @lavamoat/node recognizes a value of true but also a value of write (for globals).
Other subtasks from Design 1 apply.
Design 3
Remove all type arguments from Policy and its ilk excepting ExtraOptions.
ExtraOptionsis specifically for storing bespoke metadata in the policy and I am confident it's already handled correctly.
As long as the "extra options" are exposed within CompartmentDescriptor.policy as they should be, any T for PolicyItem<T> could instead be expressed this way. For example, an ExtraOptions containing {writable: Record<string, boolean>} could be used instead of accepting a PolicyItem containing Record<string, true | 'write'>. The consumer would then need to adjust accordingly.
Other subtasks from Design 1 apply.
I prefer this one as the result shifts complexity out of
@endo/compartment-mapperand its types.
Optional
It'd be beneficial to add a type argument which allows definition of the attenuator parameters to avoid the YOLO array in ImplicitAttenuationDefinition. This is the other place besides ExtraOptions where custom "stuff" can roundtrip through policy. @lavamoat/node currently uses type assertions to work around the lack of a type argument for this.
I'd like this too.
Security Considerations
n/a
Scaling Considerations
n/a
Test Plan
- Add some
tsdtests if we don't yet have any here. - Update policy validation tests
Compatibility Considerations
n/a
Upgrade Considerations
The types themselves will break, but whether or not this is SemVer-major depends on if & how policy validation changes (and whether anyone is actually using policy besides @lavamoat/node).
I have the feeling I'm the only person who really cares about this, but it's a little ways down my TODO list. It impacts #2894, but only hypothetically.