Skip to content

fix(security): allow dynamic stdlib member access by default; gate U006 under --lockdown (#5263)#5265

Merged
proggeramlug merged 2 commits into
mainfrom
fix/u006-dynamic-stdlib-lockdown
Jun 16, 2026
Merged

fix(security): allow dynamic stdlib member access by default; gate U006 under --lockdown (#5263)#5265
proggeramlug merged 2 commits into
mainfrom
fix/u006-dynamic-stdlib-lockdown

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Fixes #5263 (corpus Pattern B — blocks graceful-fs + fs-extra).

Problem

The #503 dynamic-stdlib-dispatch refusal (error[U006]) was always-on and over-broad. Minimal repro:

import * as fs from "node:fs";
const k = "stat" + "Sync";
fs[k]; // → error[U006] (Node → function)

It blocked legitimate packages: graceful-fs stores its retry queue on fs[Symbol.for('graceful-fs.queue')]; fs-extra wraps the known fs[method] functions.

Safety rationale

Dynamic fs[name] over a linked namespace can only reach the methods Perry already statically linked — dynamic selection among a known set, not a way to reach arbitrary code. The dispatch-by-string obfuscation it could hide is only meaningful alongside the arbitrary-code surfaces --lockdown already forbids (eval/Function, child_process, native archives). So the refusal belongs with the lockdown gate, not always-on. Default-allow is safe.

Change

Part 1 — gate U006 under lockdown (default-allow)

  • Default refuse_dynamic_stdlib_dispatch is now false (allow); the perry-hir thread-local default matches.
  • --lockdown / perry.lockdown: true / PERRY_LOCKDOWN=1 re-arms the refusal. The HIR thread-local install now runs after the lockdown ladder is fully resolved.
  • An explicit perry.allowDynamicStdlibDispatch: false / PERRY_ALLOW_DYNAMIC_STDLIB=0 still re-arms just this check (last wins), without the rest of lockdown.

Part 2 — runtime dynamic-access support

  • String keys: the codegen NativeModuleRef fast-path (js_native_module_property_by_name) now consults the module-keyed override side-table, so fs[name] = v round-trips on reads (writes already stored via NATIVE_NAMESPACE_PROP_OVERRIDES; the read path skipped it).
  • Symbol keys: the top-level fs namespace object is now cached, so a user-set symbol property (fs[Symbol.for(...)] = queue, keyed by object pointer in SYMBOL_PROPERTIES, GC-scanned) round-trips. Without caching, each NativeModuleRef minted a fresh object and the write was lost.

(Optional notice recording of allowed sites was skipped to avoid per-build noise on graceful-fs/fs-extra-heavy graphs; the access is documented as safe-by-default.)

Test evidence

Default build:

$ perry u006_repro.ts && ./u006_repro      # typeof fs["stat"+"Sync"]
function
$ # fs[Symbol.for("x")]=[1,2,3]; read back → true 3 ; fs["__custom"]=42 → 42
$ # static fs.statSync("/tmp").isDirectory() → true ; dynamic fs[k]("/tmp") → true

Gate still refuses when armed:

$ perry u006_repro.ts --lockdown           → error[U006] ... (#503)
$ PERRY_ALLOW_DYNAMIC_STDLIB=0 perry u006_repro.ts → error[U006] ... (#503)

graceful-fs / fs-extra (corpus, cwd=corpus)

before after
drivers/graceful_fs_.ts error[U006] (compile) compiles + links; next wall = runtime TypeError: Object prototype may only be an Object or null: undefined (graceful-fs clone.js { __proto__: getPrototypeOf(obj) } / Object.create — separate codegen/runtime issue, not fixed here)
drivers/fs_extra_.ts error[U006] (compile) compiles + links; same next wall

The dynamic fs[gracefulQueue] symbol access (graceful-fs.js:43/86) is exactly what this PR enables — verified working in isolation.

Tests

  • New default_allows_dynamic_stdlib_member_access HIR test (graceful-fs + fs-extra shapes).
  • Existing refuses_* tests pin the armed gate (set_refuse_dynamic_stdlib_dispatch(true)) and still pass: cargo test -p perry-hir --test dynamic_stdlib_dispatch → 24 passed.
  • cargo test -p perry-hir --lib → 154 passed; cargo test -p perry --bins → 577 passed.
  • Pre-existing unrelated red: perry-hir integration test cheerio_call_rewrite fails to compile on clean HEAD (missing byte_offset in Expr::Call literals) — not touched by this PR.

Version / changelog

Per CONTRIBUTING, version bump (Cargo.toml / Current Version) and CHANGELOG.md entry are left for the maintainer to fold in at merge. No Cargo.lock churn.

Summary by CodeRabbit

Release Notes

  • Configuration
    • Dynamic member access on standard library namespaces is now allowed by default.
    • Refusal for dynamic stdlib namespace dispatch is enforced only under --lockdown or when explicitly re-enabled via configuration/env.
  • Documentation
    • Updated dynamic dispatch and lockdown docs to reflect the new default and how lockdown re-arms the check.
  • Tests
    • Added a regression test covering default allowance for dynamic stdlib member access.
  • Runtime
    • Improved native module namespace caching for fs and ensured user overrides are applied via the fast property-read path.

…06 under --lockdown (#5263)

The #503 dynamic-stdlib-dispatch refusal (`error[U006]`) was always-on and
over-broad: it blocked legitimate packages that do dynamic member access over
the linked fs namespace — graceful-fs stores its retry queue on
`fs[Symbol.for('graceful-fs.queue')]`, fs-extra wraps the known `fs[method]`
functions. Dynamic `fs[name]` over a *linked* namespace can only select among
the methods Perry already statically linked (dynamic selection of a known set,
not arbitrary code), so it is safe to allow by default.

Part 1 — gate the refusal under lockdown instead of always-on:
- Default `refuse_dynamic_stdlib_dispatch` is now false (allow). The thread-local
  default in perry-hir matches.
- `--lockdown` (the supply-chain gate, #496) re-arms the refusal; the install
  into the HIR thread-locals now runs after the lockdown ladder is fully
  resolved.
- An explicit `perry.allowDynamicStdlibDispatch: false` /
  `PERRY_ALLOW_DYNAMIC_STDLIB=0` still re-arms just this check (last wins).

Part 2 — make dynamic access actually work at runtime:
- String-keyed reads via the codegen NativeModuleRef fast-path
  (`js_native_module_property_by_name`) now consult the module-keyed override
  side-table, so `fs[name] = v` round-trips on reads (it already stored, but
  the read path skipped the table).
- The top-level `fs` namespace object is now cached so a user-set symbol
  property (`fs[Symbol.for(...)] = queue`, keyed by object pointer in
  SYMBOL_PROPERTIES) round-trips — otherwise each NativeModuleRef minted a
  fresh object and the write was lost.

Docs: dynamic-dispatch.md + lockdown.md updated to the new default + rationale.
Tests: new default_allows_dynamic_stdlib_member_access HIR test; the existing
refuses_* tests pin the armed gate and still pass.
@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8a3784e3-d1e8-4792-8475-9b96971ae586

📥 Commits

Reviewing files that changed from the base of the PR and between dee91a8 and 5ce788b.

📒 Files selected for processing (3)
  • crates/perry-hir/src/lower/expr_member.rs
  • crates/perry-runtime/src/object/native_module.rs
  • docs/src/cli/dynamic-dispatch.md
💤 Files with no reviewable changes (1)
  • crates/perry-runtime/src/object/native_module.rs
✅ Files skipped from review due to trivial changes (2)
  • crates/perry-hir/src/lower/expr_member.rs
  • docs/src/cli/dynamic-dispatch.md

📝 Walkthrough

Walkthrough

The PR flips refuse_dynamic_stdlib_dispatch (U006) from enabled-by-default to disabled-by-default across CompilationContext, the HIR thread-local, and the compiler driver's lockdown ladder. The runtime gains fs/fs.default namespace caching and an early property-override lookup. Documentation and a regression test are updated accordingly.

Changes

Dynamic stdlib dispatch: default-allow + lockdown re-arm

Layer / File(s) Summary
Policy default flip: allow dynamic stdlib dispatch
crates/perry/src/commands/compile/types.rs, crates/perry-hir/src/ir/constants.rs
CompilationContext::new and the HIR REFUSE_DYNAMIC_STDLIB_DISPATCH thread-local are both initialized to false. Adjacent field docs are rewritten to describe the new default-allow behavior and conditions for re-arming under --lockdown or explicit opt-out.
Regression test for default-allow behavior
crates/perry-hir/tests/dynamic_stdlib_dispatch.rs
New test default_allows_dynamic_stdlib_member_access asserts that dynamic node:fs member access (string-concatenated key and Symbol.for(...)) succeeds under the default, with explicit thread-local cleanup to prevent cross-test leakage.
Lockdown ladder integration in compiler driver
crates/perry/src/commands/compile/host_config.rs
Early HIR thread-local install after env-var parsing is removed. After the full lockdown/precedence ladder resolves, ctx.refuse_dynamic_stdlib_dispatch is forced to true when ctx.lockdown is enabled, then both set_refuse_dynamic_stdlib_dispatch and set_allow_dynamic_stdlib_packages are installed in a single deferred step.
Runtime fs namespace caching and property override
crates/perry-runtime/src/object/native_module.rs
should_cache_native_module_namespace adds fs and fs.default to the cacheable-namespace set. js_native_module_property_by_name gains an early lookup against NATIVE_NAMESPACE_PROP_OVERRIDES (keyed "{module}\0{prop}"), returning stored overrides before reaching built-in resolution.
Inline documentation for dynamic dispatch execution
crates/perry-hir/src/lower/expr_member.rs
Updates the comment documenting the #503 dynamic stdlib namespace refusal pass: clarifies that refusal is off by default and re-enabled under --lockdown or explicit opt-out settings.
User-facing documentation updates
docs/src/cli/dynamic-dispatch.md, docs/src/cli/lockdown.md
dynamic-dispatch.md is rewritten from "refusal on by default" to "default allowed, re-armed under lockdown," with updated "What's checked" (scoped to "when armed"), "Opt-outs (when armed)", and rationale sections. lockdown.md clarifies that #503 dynamic stdlib dispatch is allowed by default and that --lockdown (or targeted env/config) re-arms only that check.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 A hop, a skip, the gate swings wide,
fs[runtimeVar] no longer denied!
By default we trust the linked-set call,
Lock it down with --lockdown, if at all.
graceful-fs and fs-extra, welcome home —
The rabbit's left the dispatch free to roam. 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: allowing dynamic stdlib member access by default and gating U006 under --lockdown.
Description check ✅ Passed The PR description comprehensively covers the problem, safety rationale, detailed changes (Part 1 and Part 2), test evidence, and test coverage verification.
Linked Issues check ✅ Passed All objectives from #5263 are met: default-allow dynamic stdlib member access (Part 1), support runtime fs[name] and fs[symbol] access (Part 2), verify graceful-fs/fs-extra compile/link, and lockdown still refuses U006.
Out of Scope Changes check ✅ Passed All changes are directly aligned with #5263 objectives: configuration defaults, runtime support for dynamic access, and documentation updates reflecting the new policy.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/u006-dynamic-stdlib-lockdown

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/perry-hir/src/ir/constants.rs (1)

226-228: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Stale documentation: default is now false, not true.

The doc comment says "Default is true (refusal active)" but this PR changes the default to false. Update to match the new behavior.

📝 Proposed fix
-/// `#503`: enable (true) or disable (false) the dynamic-stdlib-dispatch
-/// refusal pass. Default is true (refusal active). The compile driver
-/// calls this once with the resolved configuration before kicking off
-/// HIR lowering for the build.
+/// `#503`: enable (true) or disable (false) the dynamic-stdlib-dispatch
+/// refusal pass. Default is false (allow, per `#5263`). The compile driver
+/// calls this once with the resolved configuration before kicking off
+/// HIR lowering for the build.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-hir/src/ir/constants.rs` around lines 226 - 228, Update the doc
comment for the dynamic-stdlib-dispatch refusal pass flag to reflect the new
default value. Change the text that currently states "Default is true (refusal
active)" to indicate that the default is now false instead, ensuring the
documentation accurately matches the actual default behavior implemented in this
PR.
🧹 Nitpick comments (1)
crates/perry-runtime/src/object/native_module.rs (1)

3253-3253: ⚡ Quick win

Avoid allocating an override key on the common no-override path.

This fast path now runs before every built-in native-module property resolution; native_namespace_prop_override_get currently formats a new key even when the override map is empty. Add an empty-map guard inside the helper to keep the default path allocation-free.

♻️ Proposed helper tweak
 pub(crate) fn native_namespace_prop_override_get(module: &str, prop: &str) -> Option<f64> {
     NATIVE_NAMESPACE_PROP_OVERRIDES.with(|m| {
-        m.borrow()
-            .get(&format!("{module}\0{prop}"))
+        let m = m.borrow();
+        if m.is_empty() {
+            return None;
+        }
+        m.get(&format!("{module}\0{prop}"))
             .map(|bits| f64::from_bits(*bits))
     })
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/object/native_module.rs` at line 3253, The
native_namespace_prop_override_get function currently allocates and formats a
key even when the override map is empty, which happens on every property
resolution call. Add an early return guard at the beginning of the
native_namespace_prop_override_get helper function to check if the override map
is empty and return None immediately if it is, avoiding the unnecessary key
allocation in the common case where no overrides exist.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-hir/src/ir/constants.rs`:
- Around line 159-164: The documentation comment in
crates/perry-hir/src/lower/expr_member.rs at lines 2373-2374 states that the
refusal pass is "on by default", but this conflicts with the new default
behavior where REFUSE_DYNAMIC_STDLIB_DISPATCH is initialized to false in
constants.rs. Update the comment in expr_member.rs to accurately reflect that
the refusal pass is now disabled by default, not enabled by default, to keep the
cross-file documentation consistent.

In `@crates/perry-runtime/src/object/native_module.rs`:
- Line 3148: The fs.default cache entry is missing the bidirectional mappings
found in other default module entries. Either add fs.default to the
cjs_default_base_module map (mapping fs.default to fs) and to the
cjs_default_namespace_name map (mapping fs to fs.default) to match the pattern
used for dns.default, child_process.default, and other cached default modules,
or remove the fs.default entry from the cache if it is not actually emitted by
any lowering path. Choose the option that aligns with the actual runtime
behavior for fs.default requests.

In `@docs/src/cli/dynamic-dispatch.md`:
- Around line 21-23: The current documentation text incorrectly groups string
and symbol writes together, stating both are "stored on the namespace," which
misrepresents the actual mechanisms. Revise the sentence to clearly distinguish
between the two storage approaches: string-keyed reads (fs[name] operations) are
handled by the override side-table, while symbol-keyed operations use namespace
caching for persistence. Replace the overly broad statement with more precise
language that explains these are two separate mechanisms, not a single namespace
storage approach.

---

Outside diff comments:
In `@crates/perry-hir/src/ir/constants.rs`:
- Around line 226-228: Update the doc comment for the dynamic-stdlib-dispatch
refusal pass flag to reflect the new default value. Change the text that
currently states "Default is true (refusal active)" to indicate that the default
is now false instead, ensuring the documentation accurately matches the actual
default behavior implemented in this PR.

---

Nitpick comments:
In `@crates/perry-runtime/src/object/native_module.rs`:
- Line 3253: The native_namespace_prop_override_get function currently allocates
and formats a key even when the override map is empty, which happens on every
property resolution call. Add an early return guard at the beginning of the
native_namespace_prop_override_get helper function to check if the override map
is empty and return None immediately if it is, avoiding the unnecessary key
allocation in the common case where no overrides exist.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 22ff7541-5b45-4060-a1c6-17bb960e3a84

📥 Commits

Reviewing files that changed from the base of the PR and between c82b6ec and dee91a8.

📒 Files selected for processing (7)
  • crates/perry-hir/src/ir/constants.rs
  • crates/perry-hir/tests/dynamic_stdlib_dispatch.rs
  • crates/perry-runtime/src/object/native_module.rs
  • crates/perry/src/commands/compile/host_config.rs
  • crates/perry/src/commands/compile/types.rs
  • docs/src/cli/dynamic-dispatch.md
  • docs/src/cli/lockdown.md

Comment thread crates/perry-hir/src/ir/constants.rs
Comment thread crates/perry-runtime/src/object/native_module.rs Outdated
Comment thread docs/src/cli/dynamic-dispatch.md Outdated
…ead fs.default cache entry, clarify string-key storage docs

- expr_member.rs: refusal pass is OFF by default since #5263 (re-armed under lockdown/opt-out)
- native_module.rs: remove dead `fs.default` namespace-cache arm (referenced nowhere else; no
  cjs_default_* mappings emit it; the `fs` arm backs symbol-key persistence). fs[symbol] +
  fs[name] verified still working; dynamic_stdlib tests 24/24.
- docs/dynamic-dispatch.md: clarify string keys persist via module-keyed override table, symbol keys on cached namespace object
@proggeramlug proggeramlug merged commit 392af84 into main Jun 16, 2026
13 of 15 checks passed
@proggeramlug proggeramlug deleted the fix/u006-dynamic-stdlib-lockdown branch June 16, 2026 12:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

U006: gate dynamic-stdlib-namespace access under lockdown (default-allow) + support runtime fs[name]/fs[symbol] — unblocks graceful-fs/fs-extra

1 participant