The Node front door: wrap, unwrap, doctor, policy, trust

cruft wrap runs your existing node, npm, and npx commands through Cruft, recording what ran, reporting policy, and optionally wrapping the child in an OS sandbox, without changing how they behave. Covers wrap, unwrap, doctor, policy profiles, and the trust-install preflight, with each control's real enforcement level stated plainly.

Cruft's front door runs your existing node/npm/npx commands through Cruft, observed, policy-reported, optionally OS-sandboxed, without changing how they behave. The child is real Node, so compatibility risk is zero by construction. What Cruft adds is a supervision boundary around it.

cruft wrap -- node app.js      # run node, supervised by cruft

The design is deliberately staged: observe first, advise second, enforce only where enforcement is actually proven. This page states what each control does and what it does not do yet.

The two things it separates

Runtimes usually conflate supervision with execution. The front door splits them:

  • Supervision is Cruft's. It owns the command boundary: it records what ran, preserves cwd / env / argv / stdio / exit status, and can attach an OS-level sandbox around the child.
  • Execution is Node's. The child is the real node (or npm/npx) binary. Your workflow behaves exactly as it did before.

Every control the front door reports carries one of four levels, and the vocabulary is the product:

auditThe activity is observed and recorded. Nothing is prevented.
advisoryThe activity is warned about, then proceeds.
enforcedThe activity is actually prevented or constrained.
not-availableThe control does not exist in the current build. It is named so the absence is explicit, never implied away.

No profile name ever implies enforcement that isn't proven. cruft policy says so in its own last line: profile names do not imply install blocking, malicious-package blocking, or OS/process sandboxing unless a control reads level=enforced.

cruft wrap

cruft wrap [--explain] [--audit] [--audit-log <path>]
           [--policy <profile>] [--sandbox <profile>] -- <program> [args...]

Runs <program> as a supervised child. The child inherits stdin, stdout, and stderr, inherits the parent's working directory, and its exit code is passed back faithfully (masked to the low 8 bits, as a shell would; a child killed by a signal surfaces as exit 1). A bare cruft wrap -- node app.js prints nothing at all and simply supervises.

Flags

FlagEffect
--explainPrint a one-line backend/command summary to stderr before running
--auditPrint an audit summary line (cwd, argv count, env mode)
--audit-log <path>Append a JSONL audit record (schema below); also accepts --audit-log=<path>
--policy <p>Report under a named policy profile; also accepts --policy=<p>
--sandbox <p>Wrap the child in an OS sandbox profile; also accepts --sandbox=<p>

The -- separator is optional: the first non-dash token is taken as the program, so cruft wrap node app.js is identical to cruft wrap -- node app.js. Use -- only when the program name itself begins with a dash.

The transcript (the policy block) is emitted only when at least one of --explain, --audit, --policy, or --sandbox is present. Passing --sandbox alone turns the transcript on as a side effect (and reports under the ci profile, see below).

Exit codes

CodeTrigger
child's codenormal completion (0-255, masked)
1the child was killed by a signal, or a home-directory / IO error
64a usage error: unknown option, missing --audit-log/--policy/--sandbox argument, unknown --sandbox or --policy profile, or no program given
66the child could not be spawned, or the sandbox profile file could not be written
78--sandbox was requested on a platform where the profile is not supported

An unknown --sandbox value is a hard error (exit 64); an unknown --policy value is also a hard error (exit 64): cruft wrap: unknown policy profile "<value>"; supported: audit, ci.

Pinning the Node binary with CRUFT_NODE

When the program name is the literal node, Cruft resolves the actual binary from the CRUFT_NODE environment variable, falling back to node on PATH. This lets a wrapped environment pin exactly which Node runs. It applies only to the literal node; npm and npx are always resolved from PATH. Note that this is a plain CRUFT_-prefixed variable with no CRUFTLESS_ alias, the front-door commands read no CRUFTLESS_* variables at all.

The audit-log record

--audit-log <path> appends one JSON object per run. The full shape:

{
  "schema_version": 1,
  "tool": "cruft frontdoor audit",
  "command": "cruft wrap",
  "cwd": "/path/to/project",
  "argv": ["node", "-e", "console.log(\"x\")"],
  "resolved_binary": "/Users/you/.nvm/.../bin/node",
  "package_manager": { "name": "npm", "version": "10.9.8" },
  "backend": "node",
  "policy_profile": null,
  "evidence": null,
  "lifecycle_script_facts": [],
  "policy": [ { "control": "...", "level": "...", "reason": "..." }, ... ],
  "outcome": { "exit_code": 0 }
}

The record is written on both success and spawn failure. The resolved_binary is the real Node the wrapper execd after CRUFT_NODE resolution.

The OS sandbox: --sandbox=macos-strict

--sandbox is where a control flips from not-available to enforced. The only profile today is macos-strict, and on macOS it is real: it runs the child under the system sandbox-exec with a generated profile.

cruft wrap --sandbox=macos-strict -- node build.js

The generated profile is a small allow-by-default policy with targeted denies:

(version 1)
(allow default)
(deny network*)
(deny file-write*)
(deny process-exec)
(allow process-exec (literal "<the resolved child executable>"))

So under macos-strict the child:

  • cannot write to the filesystem (file-write* denied). A fs.writeFileSync fails with EPERM.
  • cannot open the network (network* denied). A socket connect fails with EPERM.
  • runs with a scrubbed environment. Cruft clears the child's env and re-adds only PATH (a safe default), HOME, TMPDIR, and a CRUFT_WRAP_SANDBOX marker. This is the environment control, and it is enforced by the launch wrapper, not by the sandbox profile text.
  • can still read files and write to the inherited stdout/stderr, so ordinary program output is unaffected.

The profile is written to a per-run temp directory and removed after the child exits, whether the run succeeds or fails.

The limit of the sandbox

The profile re-allows executing exactly one binary: the child it launched. That means a Node process can still spawn another copy of the same executable. Full child-process denial is therefore not provided, and Cruft labels it complete-child-process-denial: not-available rather than implying a guarantee it can't back. There is also no Linux or Windows sandbox profile; --sandbox=macos-strict on a non-macOS build refuses with exit 78. Treat the sandbox as a real but bounded macOS-only control.

Policy profiles: --policy and cruft policy

cruft policy [audit|ci|paranoid|locked] prints the control table for a profile; --policy on cruft wrap reports the same table around a run. The controls and their levels:

Controlaudit profileci / paranoid / lockedMeaning
command-transcriptauditauditCommand/cwd/argv/env summary recorded when a transcript is requested
subprocess-supervisionenforcedenforcedThe wrapper boundary preserves cwd/env/argv/stdio/exit
dependency-scanauditadvisoryOSV and package-risk facts reported, installs not blocked
lifecycle-scriptsauditadvisoryInstall scripts observed and reported, not blocked
native-addonsauditadvisoryNative addon markers observed, not blocked
runtime-substitutionenforcedenforcedCruft runtime selected only on explicit backend or exact packaged evidence
install-blockingnot-availablenot-availableDefault flows never block installs; cruft trust install --enforce can
malicious-package-blockingnot-availablenot-availableDefault flows report advisories; trust install --enforce can block
os-process-sandboxnot-availablenot-availableNode children are not constrained by Cruft caps unless --sandbox is given

Two things to read carefully here:

  • The escalating profile names do not escalate enforcement. ci, paranoid, and locked produce an identical table to each other; the only difference from audit is that three controls read advisory instead of audit (a reporting-tone change with no behavior change). No profile turns any not-available control into enforced. If you want enforcement, you reach for --sandbox (OS sandbox) or cruft trust install --enforce (install blocking), not for a profile name.
  • The only controls that are ever enforced by a profile are subprocess-supervision and runtime-substitution. Everything stronger is opt-in through a different, explicit command.

The shim layer: cruft wrap install

cruft wrap install --dry-run    # show exactly what would change
cruft wrap install              # install the managed shims

This is the persistent form of the front door. It writes shim executables for node, npm, and npx into ~/.cruft/bin, records a manifest at ~/.cruft/node-wrapper/manifest.json, and adds a marker-owned PATH block to your shell profile so those shims win on PATH.

Each shim is a short sh script that remembers the real binary it shadowed and execs it back through the wrapper:

#!/bin/sh
# BEGIN CRUFT MANAGED NODE WRAPPER
CRUFT_WRAPPED_NODE='/Users/you/.nvm/.../bin/node'
export CRUFT_WRAPPED_NODE
exec '/path/to/cruft' wrap -- '/Users/you/.nvm/.../bin/node' "$@"
# END CRUFT MANAGED NODE WRAPPER

After install, every node/npm/npx invocation in a fresh shell flows through cruft wrap automatically. The PATH block added to your profile is exactly:

# BEGIN CRUFT MANAGED NODE WRAPPER
export PATH='/Users/you/.cruft/bin':$PATH
# END CRUFT MANAGED NODE WRAPPER

The manifest records enough to reverse everything:

{
  "version": 1,
  "created_by": "cruft wrap install",
  "shim_dir": "/Users/you/.cruft/bin",
  "profile_blocks": ["/Users/you/.zshrc"],
  "managed_commands": ["node", "npm", "npx"],
  "cruft_binary": "/path/to/cruft",
  "prior_commands": { "node": "...", "npm": "...", "npx": "..." }
}

The design discipline is reversibility: every file Cruft writes is either inside Cruft-owned directories or inside explicit BEGIN/END CRUFT MANAGED NODE WRAPPER markers, and the manifest records the prior state. Nothing outside the markers is ever touched. (Install then cruft unwrap --all restores the shell profile byte-for-byte.)

Which profile file it targets. Install writes to ~/.zshrc by default, or to the file named by the CRUFT_WRAP_PROFILE environment variable if set. It writes only that one file. If your interactive shell is bash and reads ~/.bashrc or ~/.bash_profile instead, the default install lands in .zshrc and will not activate, point CRUFT_WRAP_PROFILE at the right file, or add the PATH block yourself. (status and unwrap do scan the common bash profile names for markers, so cleanup still finds a block wherever it lives.)

cruft wrap status

Reports the wrapper state with evidence:

  • inactive: no manifest and no marker block anywhere.
  • partial: some state exists but is incomplete (missing shim dir, absent markers, no managed commands); the report names exactly what.
  • active-current-shell: every managed command resolves to the shims right now in this process's PATH.
  • active-next-shell: installed, but at least one managed command still resolves outside the shim in this process (a fresh shell, or a PATH reorder, is needed). The report prints the exact export PATH=... to fix it now, and points at the usual culprits: Homebrew, nvm, fnm, asdf, or Volta PATH ordering.

cruft unwrap

cruft unwrap --dry-run    # enumerate every change first
cruft unwrap              # remove cruft-managed wrapper state
cruft unwrap --all        # same removal, with an explicit "all state" scope line

Removes Cruft-managed wrapper state and only that: the marker-owned profile blocks (wherever they are found), the shims (only files that actually carry the marker pair, so a user file that happens to share the name is left alone), and the manifest. A shim directory is removed only if it is empty afterward. The --dry-run enumerates every change before you commit. --all currently only adds a scope line to the output; the removal set is already comprehensive without it.

cruft doctor

cruft doctor is the first-run diagnosis. It explains the difference between Cruft-runtime execution and Node-wrapper execution, reports the wrapper state with per-command resolution (is node currently yours or the shim?), and reports platform sandbox capability with its limitations spelled out.

cruft doctor --json emits the same facts as a structured object for CI and agents: project, wrapper (with per-command through_cruft booleans), backend, sandbox_profiles, sandbox_controls (each with a predicate), policy, and next_steps. Two fields are currently always empty in the JSON form, evidence and risks, even though the trust path computes real risk data; do not treat the doctor JSON as a risk report (use cruft trust install for that).

cruft trust install

The npm-install preflight:

cruft trust install -- npm install            # advisory: report risks, then run
cruft trust install --enforce -- npm install  # block known-bad before npm launches

The command after -- must be an npm install (npm install, npm i, or npm add); anything else is a usage error. Advisory by default, it inspects the project and classifies what the install is about to do:

lifecycle-scriptA preinstall/install/postinstall/prepare script is present (from package.json or the lockfile's hasInstallScript).
native-addonA binding.gyp sibling or a gypfile:true marker (a native build step).
git-dependencyA dependency spec pointing at a git source (git+, git:, github:, gitlab:).
tarball-dependencyA dependency spec that is an http(s) URL or a .tgz.
novelty-package-riskA floating spec (* or latest).
known-malicious-advisory / known-vulnerabilityAn OSV advisory match (malicious if the advisory id is a MAL- prefix, otherwise a vulnerability).

It then passes through to npm and returns npm's own exit code.

What --enforce actually blocks. With --enforce, an install is blocked before npm launches (exit 77) only for three risk kinds: lifecycle scripts, native addons, and known-malicious (MAL-) advisories. The other classes, including ordinary known-vulnerability OSV matches and the git / tarball / novelty specs, are reported but still pass through even under --enforce. There is no package-age heuristic; "novelty" here means only a * or latest spec. Size that precisely: --enforce gates lifecycle, native-addon, and known-malicious risks only.

The OSV lookup shells out to curl with a short timeout (and can be pointed at a local fixture via CRUFT_OSV_FIXTURE for offline or test use). A network failure degrades to zero OSV risks, not an error.

Backend selection: cruft run --backend

The front door also reaches into cruft run. --backend chooses the execution engine per invocation and --explain shows the reasoning:

$ cruft run --backend=auto --explain app.mjs
cruft run: backend=auto selected=node reason=no packaged exact compatibility evidence matched this mouth
cruft run: policy=evidence-advisory override with --backend=node or --backend=cruft
cruft run: note Node child processes are not constrained by Cruft runtime caps
  • --backend=cruft: force the Cruft runtime (for proven code).
  • --backend=node: force real Node (maximum compatibility).
  • --backend=auto: evidence-driven. Cruft's runtime is selected only when packaged compatibility evidence exactly matches the code being run; otherwise Node. This is the runtime-substitution control's enforced guarantee: auto never gambles your workload on unproven parity.

How this relates to the rest of Cruft

The front door and the runtime are complementary bets on the same thesis:

  • Running code on Cruft's own runtime gets the full capability system, sealed mode, per-module attribution, and compartments (Compartments and capabilities).
  • Running code through the front door on Node gets supervision, transcripts, a risk preflight, and an OS sandbox, with zero compatibility risk because the engine is Node itself.

The staged promise is migration surface by surface: observed on Node today, promoted to the Cruft runtime where evidence proves parity, with the reversible wrapper boundary as the constant supervision layer throughout.

Limitations

The current limits in one place:

  • Policy profile names are cosmetic: ci, paranoid, and locked behave identically; no profile raises a not-available control to enforced.
  • The OS sandbox is macOS-only and bounded: it denies network, file writes, and general exec, and scrubs the environment, but it re-allows the child to re-exec its own binary, so complete child-process denial is not provided.
  • Install blocking is narrow: trust install --enforce blocks only lifecycle-script, native-addon, and known-malicious risks; vulnerabilities and git/tarball/novelty specs pass through.
  • Node children are never constrained by Cruft's runtime capability system; only the OS sandbox constrains them, and only on macOS.
  • The default install targets ~/.zshrc only; bash users must point CRUFT_WRAP_PROFILE at their profile.

None of these are hidden by the tool: cruft policy, cruft doctor, and the not-available labels state each one; this page collects them in one place.