feat(security): #503 — refuse compile-time dynamic dispatch on stdlib namespaces#941
Merged
Merged
Conversation
… namespaces
Adds a compile-time HIR pass that rejects `obj[runtimeVar]()` and
`obj[atob(...)]()` shapes when `obj` resolves to a Node-core stdlib
namespace (process, fs, crypto, child_process, net, os, path, http,
https, http2, stream, url, util, events, dns, tls, querystring, zlib,
async_hooks, readline, string_decoder, tty, worker_threads). Catches the
dispatch-by-string class of supply-chain evasion used by malicious npm
packages — `process["bind"+"ing"]("dns")`, `fs[atob("...")]()`, etc.
Zero runtime cost — purely a compile-time refusal. Lives in the
platform-agnostic HIR pass so it applies uniformly to every backend
(native LLVM, WASM, ArkTS/HarmonyOS, JS, Glance, SwiftUI).
Opt-outs (priority order, listed in the error message itself):
- Static call: `process.exit(0)` — preferred fix.
- `// @perry-allow-dynamic` line annotation on or above the call.
- `perry.allowDynamicStdlibDispatch: ["@scope/dep"]` per-package allow
list in the host package.json (matches `node_modules/<pkg>/...`).
- `perry.allowDynamicStdlibDispatch: true` global disable.
- `PERRY_ALLOW_DYNAMIC_STDLIB=1` env var for one-off builds; `=0`
enforces the check even when package.json opts out (CI gate).
User-code reflection on user-defined objects is untouched — the check
only fires when the receiver resolves to a known stdlib namespace
(including aliased shapes like `import fs from 'fs'`, `import * as fs
from 'fs'`, and `(process as any)[k]`).
Plumbing follows the existing #665 thread-local pattern in
`collect_modules.rs`: per-thread config + per-module source-text stash
for the annotation lookup, both cleared after each `lower_module_full`
call so rayon worker state can't leak across modules.
Test coverage in `crates/perry-hir/tests/dynamic_stdlib_dispatch.rs`:
- bare ident, imported alias, namespace import all refused
- literal-string key, user-object dispatch unaffected
- site annotation works above and on the call line (and through
stacked comment lines like `// @ts-ignore`)
- per-package allow list and global disable both opt out cleanly
- diagnostic names the namespace and every opt-out mechanism
End-to-end smoke-tested by compiling the three opt-out shapes against
the release binary.
This was referenced May 18, 2026
proggeramlug
added a commit
that referenced
this pull request
May 19, 2026
…#1051) #503/#941 honored the `// @perry-allow-dynamic` site annotation regardless of source path. Since the threat model is malicious npm packages, a hostile dep could defeat the check by writing the annotation next to its own call. Closed by gating the site annotation on host-code-only paths: when `package_name_for_source_path` returns `Some(_)` the annotation is ignored. Dependencies that legitimately need dynamic dispatch must opt in via the host's `perry.allowDynamicStdlibDispatch` allow-list or the global flag — the per-package path is host-controlled by design. Also folded in: - new `site_annotation_ignored_inside_node_modules` regression test with host-code counter-assertion - doc drift fix in `crates/perry-hir/src/ir.rs` REFUSE_DYNAMIC_STDLIB_DISPATCH thread-local docstring: added `http2`, `async_hooks`, `readline`, `string_decoder`, `tty`, `worker_threads`; removed `buffer` (Buffer is a constructor, intentionally excluded), with a pointer to the canonical list in `lower/expr_member.rs::STDLIB_NAMESPACE_NAMES` - `docs/src/cli/dynamic-dispatch.md` annotation section now spells out that the opt-out is host-code only
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #503.
Summary
obj[runtimeVar]()andobj[atob(...)]()shapes whenobjresolves to a Node-core stdlib namespace (process,fs,crypto,child_process,net,os,path,http,https,http2,stream,url,util,events,dns,tls,querystring,zlib,async_hooks,readline,string_decoder,tty,worker_threads).process["bind"+"ing"]("dns"),fs[atob("...")](),child_process[methodName]().What's untouched
fs["readFileSync"]("/tmp/x")) fold toPropertyGetand always pass.import * as nsnamespace import).Opt-outs (listed in the error message itself)
process.exit(0).// @perry-allow-dynamicon or above the call site. Stacks with// @ts-ignoreand similar.perry.allowDynamicStdlibDispatch: ["@scope/dep"]in the hostpackage.json, matched againstnode_modules/<pkg>/….perry.allowDynamicStdlibDispatch: truein the hostpackage.json.PERRY_ALLOW_DYNAMIC_STDLIB=1for one-off builds;=0enforces the check even whenpackage.jsonopts out, so CI can gate.Implementation
Plumbing follows the existing #665 thread-local pattern in
collect_modules.rs:set_refuse_dynamic_stdlib_dispatch,set_allow_dynamic_stdlib_packages) installed before eachlower_module_fullcall.set_current_module_source) lets the annotation lookup resolve// @perry-allow-dynamicwithout re-reading the file from disk.The check sits in
lower_member.rs::lower_member'sMemberProp::Computedarm, before index lowering, and unwraps TS type wrappers (Paren,TsAs,TsTypeAssertion,TsNonNull,TsConstAssertion,TsSatisfies) so idiomatic(process as any)[k]()still surfacesprocessas the receiver.The error message names the namespace, cites issue #503, and walks the user through every opt-out path.
Files
crates/perry-hir/src/ir.rs— thread-locals +package_name_for_source_pathhelper + annotation-lookup helper.crates/perry-hir/src/lower/expr_member.rs— the refusal check +stdlib_namespace_receiverhelper (handles bare ident,builtin_module_aliases, namespace imports, TS-wrapper unwrapping).crates/perry/src/commands/compile.rs— parsesperry.allowDynamicStdlibDispatchfrompackage.json(boolean or array), env-var override, applies config to the driver thread.crates/perry/src/commands/compile/collect_modules.rs— re-installs config on each rayon worker before lowering.crates/perry/src/commands/check.rs— installs the source-text thread-local duringperry checkso site annotations resolve there too.crates/perry-hir/tests/dynamic_stdlib_dispatch.rs— 11 unit tests covering refusal, every opt-out path, namespace-extraction edge cases, and the diagnostic text.docs/src/cli/dynamic-dispatch.md+docs/src/SUMMARY.md— user-facing documentation.Test plan
cargo test -p perry-hir— 118 tests pass (incl. 11 new).cargo test --release -p perry --tests— 244 tests pass.cargo build --release -p perry-runtime -p perry-stdlib -p perry -p perry-codegen-wasm -p perry-codegen-arkts -p perry-codegen-swiftui -p perry-codegen-glance -p perry-codegen-js— all platform backends build clean.(process as any)[k](0)fails with the new diagnostic; literal-key,// @perry-allow-dynamic, andPERRY_ALLOW_DYNAMIC_STDLIB=1all compile cleanly.Notes
Cargo.tomlversion bump, noCLAUDE.mdversion line touch, noCHANGELOG.mdentry — per the external-contributor PR policy / to avoid version-bump collisions while this is in review. Maintainer will fold those in at merge time.