Skip to content

feat(compartment-mapper): support mapping of undiscoverable packages #2856

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed

Conversation

boneskull
Copy link
Member

@boneskull boneskull commented Jun 12, 2025

Description

This adds options to mapNodeModules(), captureFromMap(), loadFromMap() and importLocation() to support loading additional packages that wouldn't otherwise be discovered by mapNodeModules(). It takes several ideas from #2864 which were not already implemented in #2876.

The first option is additionalModuleLocations, which is an Array<string | AdditionalModuleLocationObject> where items can either be fully-qualified file:// URLs or an object with shape:

export interface AdditionalModuleLocationObject {
  location: string;
  dev?: boolean;
  conditions?: Set<string>;
}

Using the above object, consumers have granular control over certain options when graphing additional modules. If dev or conditions are provided ("not undefined") as options to mapNodeModules() directly, the additional modules will inherit those settings (there is coverage for this).

The second option, additionalPackageDetails, is a superset of AdditionalModuleLocationObject which also contains a package descriptor and package location. This array can then be handed to captureFromMap() or loadFromMap() after it has been populated.

In mapNodeModules(), additionalModuleLocations causes graphPackages() to be called against each location while reusing the Graph. The internal API has been changed somewhat to allow this. Because we do not know whether or we will be executing using the resulting CompartmentMapDescriptor (e.g. we can provide it to captureFromMap() which will not invoke dynamic requires), mapNodeModules() sets the CompartmentDescriptor.retained flag. When it does, it also sets the retained flag for each ModuleDescriptor found within CompartmentDescriptor.modules. It does this as the last step before returning the CompartmentMapDescriptor and only does so on CompartmentDescriptors not already found prior to graphing additional modules. This is a recursive operation which bails out of if either a) the CompartmentDescriptor was graphed by way of the entry module, or b) we've seen the CompartmentDescriptor before (to protect against cycles).

The supporting implementation in other modules is minimal excepting a change to loadFromMap where all additional packages are eagerly loaded via compartment.load(additionalPackageLocation).

Tests are concentrated in a new test suite (additional-modules.test.js) which covers the aforementioned APIs using the same comprehensive fixture.

Also:

  • Fixed signature of chooseModuleDescriptor
  • Fixed deprecation notice for compartmentMapForNodeModules; it was previously on the options object, but it should only be on the export.

Questions for Reviewers

  • Naming is hard and I was not sure what to name the new options. The AdditionalPackageDetails type looks like the existing PackageDetails type (with a new field) thus influencing the naming—but they are otherwise unrelated and because of this I did not extend PackageDetails.

  • I am monkeying with imports in a RecordModuleDescriptor object in order to direct SES' module loading. Is there a better way? The things I stuff in imports are also extremely dubious. I am not doing this anymore. Do not worry about it.

  • Because additional packages are crawled similarly to the entry package and we do not tell Endo what package should be expected to import the additional package, the CompartmentDescriptor.path for this package would naturally be [] w/o any other intervention.

    Since that seems obviously wrong to me—because only the entry CompartmentDescriptor can have an empty path—it is now always set to [CompartmentDescriptor.name].

    IMO this is less wrong; the additional package must be reachable (at runtime) via the entry package (otherwise why would we specify it as an additional package?), but the additional package is not necessarily directly reachable (i.e. not a direct dependency) from the entry package.

    This has an impact on shortest path calculation, but I feel like a stable value for CompartmentDescriptor.path is more important than a logically-consistent one.

  • Given the previous point, consider: the only way we would know which package directly (dynamically) imports this "additional" package is if the user explicitly told us which. One way to do this is via package policy, but we do not currently consider policy to be an indicator of a dependency relationship. Further, policy is not required to leverage additional packages.

    If we cannot use policy (though it would seem handy!), we would need to do something like change the shape of AdditionalModuleLocationObject (see above) to allow a new field which would presumably be the package location of whatever directly imports the module provided in AdditionalModuleLocationObject.location. If we had that information, we could use it to build logically consistent paths.

  • @naugtur's implementation was using a special prefix in the canonical name for these "additional packages". I don't know what purpose this serves (other than he mentioned something about LavaMoat having some historical support for it).

Tangentially: since we expect policy to contain canonical names which have not yet been computed (which is backwards IMO, but I understand the tradeoffs), we should catch unused and/or unexpectedly empty package policies and report them. @lavamoat/node does some of this work already, but I feel like it should be happening in @endo/compartment-mapper instead.

Security Considerations

n/a

These changes do not meddle with systems loading untrusted code, afaik.

Scaling Considerations

Use of the new option is not free.

Documentation Considerations

n/a

Testing Considerations

n/a

Compatibility Considerations

No.

Upgrade Considerations

This warrants an entry in NEWS.md since it is part of a public API.

@boneskull boneskull self-assigned this Jun 12, 2025
@boneskull boneskull added the enhancement New feature or request label Jun 12, 2025
@boneskull boneskull requested review from kriskowal and naugtur June 12, 2025 21:36
`${compartmentDescriptors[additionalPackageLocation].name}${additionalPackageModuleSpecifier.substring(1)}`,
);
} else {
throw new Error(
Copy link
Member Author

Choose a reason for hiding this comment

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

the only way for this to happen would be to provide a broken CompartmentMapDescriptor in the first place. IDK if it's worth worrying about.

Copy link
Member

@naugtur naugtur left a comment

Choose a reason for hiding this comment

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

Review in progress, but I think there might be a less (opinion) invasive way to serve the same need.

That being said, I want to encourage others to review because this exists and my hypothetical other way doesn't (at least until I try)

@boneskull boneskull marked this pull request as draft June 30, 2025 21:33
@boneskull
Copy link
Member Author

This is gonna look like a mashup of #2864 and #2878. I am actually going to change this PR to target #2878.

@boneskull
Copy link
Member Author

@naugtur Regarding setting the retained flag:

AFAICT it is needed, since we do not know what will happen with the compartment descriptor map after it exits mapNodeModules().

If it is provided to loadFromMap(), then you're right; setting it shouldn't be necessary.

If it is provided to captureFromMap(), then it is necessary because no execution occurs and there's no other way to tell the "digest" function that we should keep these compartments around.

Whether or not to set this bit could potentially be a flag to mapNodeModules, I suppose.

The latest change will actually set this flag recursively. It should be possible to narrow the set by determining which compartment descriptors are only referenced by the additional modules and other like descriptors. I haven't done that yet, but I think it makes sense to do since the additional module may reference the entry point (so we've just flagged the whole compartment map).

An alternative design (which may be a better idea, but idk) would involve fiddling with the digest function to force it to keep certain compartments.

@boneskull
Copy link
Member Author

Also: I noticed that we need explicit control over the dev (and possibly conditions, though I don't use it personally) flag for each additional module.

For example, if our entrypoint is node_modules/.bin/webpack, we unequivocally do not want to map its dev deps. However the package containing our config file ./webpack.config.js should have its dev deps looked at!

@boneskull boneskull force-pushed the boneskull/map-node-modules-additional-entries branch from 0ba0952 to 31d52de Compare July 9, 2025 01:02
@boneskull boneskull changed the base branch from master to boneskull/compartment-mapper-revisit-findRedirect-dynamic-requires-2876 July 9, 2025 01:06
@boneskull boneskull marked this pull request as ready for review July 9, 2025 01:06
@boneskull boneskull requested a review from naugtur July 9, 2025 01:07
@boneskull boneskull force-pushed the boneskull/compartment-mapper-revisit-findRedirect-dynamic-requires-2876 branch from 32e87c6 to 577eaf8 Compare July 9, 2025 21:54
@boneskull
Copy link
Member Author

working on sorting out the merge conflict

@boneskull boneskull force-pushed the boneskull/map-node-modules-additional-entries branch 4 times, most recently from a4fb5c6 to f860560 Compare July 11, 2025 00:22
@boneskull boneskull force-pushed the boneskull/compartment-mapper-revisit-findRedirect-dynamic-requires-2876 branch from 577eaf8 to 6bf2f4f Compare July 11, 2025 18:51
@boneskull boneskull force-pushed the boneskull/map-node-modules-additional-entries branch from f860560 to 80fdac9 Compare July 11, 2025 18:51
@boneskull boneskull force-pushed the boneskull/compartment-mapper-revisit-findRedirect-dynamic-requires-2876 branch from 6bf2f4f to 0728447 Compare July 11, 2025 19:25
@boneskull boneskull force-pushed the boneskull/map-node-modules-additional-entries branch from 80fdac9 to 371504b Compare July 11, 2025 19:26
@boneskull boneskull force-pushed the boneskull/map-node-modules-additional-entries branch 2 times, most recently from 49809df to fbf783c Compare July 15, 2025 21:12
…rror messaging

- Adds a reusable function, `policyEnforcementFailureMessage()`, to generate helpful error messages when policy enforcement fails
- Narrows `PolicyOption` type
- Loosens `FullAttenuationDefinition` type, wherein `params` is actually an optional field per the extant test suites
- `policy-format.js`:
  - Moves `generateCanonicalName()` here
  - Updates `PackageNamingKit.name` for use in this context
  - Adds more type guards and assertions for validation
  - Adds `or()`, `and()` and `not()` which can be used to compose type guards in a type-safe way
  - Moved `ATTENUATORS_COMPARTMENT` here to avoid cycle
- Added some helpers to `typescript.ts`
- Update snapshots to reflect slight changes to error messaging

# Conflicts:
#	packages/compartment-mapper/src/node-modules.js
…resort package policy lookup

This adds `enforcePackagePolicyByPath()` which accepts two (2) `CompartmentDescriptor`s (_A_ and _B)_ and determines if _B_ is allowed access to _A_ via package policy.  `findRedirect()`, called by `importNowHook()`, now uses this if it cannot enforce policy any other way.  This solves the issue described in #2876, which is that a package needs to dynamically require a module from an otherwise-unrelated-except-by-policy `CompartmentDescriptor`.

Slightly changed the existing error hint to differentiate this case.

Fixes #2876
This adds options to `mapNodeModules()`, `captureFromMap()`, `loadFromMap()` and `importLocation()` to support loading additional packages that wouldn't otherwise be discovered by `mapNodeModules()`.  It takes several ideas from #2864 which were not already implemented in #2876.

The first option is `additionalModuleLocations`, which is an `Array<string | AdditionalModuleLocationObject>` where items can either be fully-qualified `file://` URLs or an object with shape:

```ts
export interface AdditionalModuleLocationObject {
  location: string;
  dev?: boolean;
  conditions?: Set<string>;
}
```

Using the above object, consumers have granular control over certain options when graphing additional modules.  If `dev` or `conditions` are provided ("not `undefined`") as options to `mapNodeModules()` directly, the additional modules will _inherit_ those settings (there is coverage for this).

The second option, `additionalPackageDetails`, is a superset of `AdditionalModuleLocationObject` which also contains a package descriptor and package location.  This array can then be handed to `captureFromMap()` or `loadFromMap()` after it has been populated.

Because the `CompartmentMapDescriptor` can only have a single `CompartmentDescriptor` with an empty `path` (the entry), the `path` of each additional package is its name.  Related `CompartmentDescriptors`

In `mapNodeModules()`, `additionalModuleLocations` causes `graphPackages()` to be called against each location while reusing the `Graph`. The internal API has been changed somewhat to allow this. Because we do not know whether or we will be executing using the resulting `CompartmentMapDescriptor` (e.g. we can provide it to `captureFromMap()` which will not invoke dynamic requires), `mapNodeModules()` sets the `CompartmentDescriptor.retained` flag. When it does, it also sets the `retained` flag for each `ModuleDescriptor` found within `CompartmentDescriptor.modules`.  It does this as the _last_ step before returning the `CompartmentMapDescriptor` and only does so on `CompartmentDescriptor`s not already found prior to graphing additional modules. This is a recursive operation which bails out of if either a) the `CompartmentDescriptor` was graphed by way of the entry module, or b) we've seen the `CompartmentDescriptor` before (to protect against cycles).

The supporting implementation in other modules is minimal excepting a change to `loadFromMap` where all additional packages are eagerly loaded via `compartment.load(additionalPackageLocation)`.

Tests are concentrated in a new test suite (`additional-modules.test.js`) which covers the aforementioned APIs using the same comprehensive fixture.

Also:

- Fixed signature of `chooseModuleDescriptor`
- Fixed deprecation notice for `compartmentMapForNodeModules`; it was previously on the options object, but it should only be on the export.
- Passed `log` option thru `importLocations`
- Added explicit type for `LoadFromMapOptions` (it was previously using `ImportLocationOptions`, but it is now slightly different)
- `makeModuleMapHook()` now accepts a `Record<string, CompartmentDescriptor>` parameter (ostensibly from `CompartmentMapDescriptor.compartments`) for the purpose of error reporting, which can now reference canonical names / paths
@boneskull boneskull force-pushed the boneskull/map-node-modules-additional-entries branch from fbf783c to 0c3e081 Compare July 15, 2025 21:14
@boneskull boneskull force-pushed the boneskull/compartment-mapper-revisit-findRedirect-dynamic-requires-2876 branch from 0728447 to 18b0de6 Compare July 15, 2025 21:17
@boneskull boneskull force-pushed the boneskull/compartment-mapper-revisit-findRedirect-dynamic-requires-2876 branch 3 times, most recently from ff67972 to 9f7d098 Compare July 24, 2025 22:41
Base automatically changed from boneskull/compartment-mapper-revisit-findRedirect-dynamic-requires-2876 to master July 24, 2025 22:49
@boneskull boneskull marked this pull request as draft July 25, 2025 19:08
@boneskull
Copy link
Member Author

I'm going to close this in lieu of a forthcoming PR.

@boneskull boneskull closed this Jul 29, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants