Skip to content

feat(mappings): add four-tier macOS-to-nix-darwin mapping layer - #21

Draft
wgordon17 wants to merge 29 commits into
gordon-code:mainfrom
wgordon17:roadmap/phase-1/mapping-layer
Draft

feat(mappings): add four-tier macOS-to-nix-darwin mapping layer#21
wgordon17 wants to merge 29 commits into
gordon-code:mainfrom
wgordon17:roadmap/phase-1/mapping-layer

Conversation

@wgordon17

Copy link
Copy Markdown
Member

Summary

  • Adds src/mac2nix/mappings/ — 9 modules (defaults, apps, home-manager programs, dotfiles, brew formulae, app configs, ephemeral-state filtering, non-defaults options, and a four-tier classifier) mapping scanned macOS settings to nix-darwin destinations
  • Redacts sensitive values (API keys, tokens, passphrases) from shell aliases, env vars, and preferences before they reach classification output, since the preferences scanner doesn't redact upstream like other scanners do
  • 307 new tests (1506 passed repo-wide), ruff clean, pyright 0 errors

wgordon17 added 29 commits July 20, 2026 11:55
Ghostty cannot build from source on Darwin via nixpkgs (no Swift 6/
xcodebuild support in the sandboxed builder). pkgs.ghostty-bin is the
correct Darwin-buildable package.
…g registry into classifier

Adds ENVIRONMENT_MAP (aliases/env_vars/path_components -> environment.*
nix-darwin options) and expands classify_shell_setting() to emit
ClassificationResults for ShellConfig.aliases/env_vars/path_components,
routing frameworks/dynamic_commands to Tier 4 manual report. Wires the
previously-unreachable TIMEZONE_NIX_OPTION into classify_system_setting()
for the "timezone" field, and adds classify_app_config() to route
app_config_registry lookups through the four-tier classifier.
…onfigInfo.config_paths

HMModuleInfo and NixpkgsEquivalent were plain @DataClass while every
other lookup-result type in this layer is frozen+slots; they are stored
in module-level "constant" dicts and should not be mutable. Also changes
AppConfigInfo.config_paths from list[str] to tuple[str, ...] since a
mutable list inside a frozen dataclass could still be corrupted via
in-place mutation.

Additionally deep-copies the raw_plist snapshot in classify_launch_agent()
(previously a shallow dict comprehension shared nested dict/list references
with the LaunchAgentEntry the orchestrator still holds) and documents that
Phase 6 generators must check metadata.get("skipped") before surfacing
MANUAL_REPORT entries in a manual-steps report.
…duleInfo paths

classify_shell_setting() had three near-identical if-blocks for
aliases/env_vars/path_components that only differed by field name;
collapsed into a loop over ENVIRONMENT_MAP's keys, which match the
ShellConfig field names exactly.

HMModuleInfo.config_paths/darwin_config_paths were still list[str]
with mutable defaults despite the class being frozen+slots in the
prior commit -- the same corruption risk AppConfigInfo.config_paths
was just fixed for. Converted both to tuple[str, ...] for consistency
and updated the 40+ registry entries and tests accordingly.
…fore classification

classify_shell_setting() wrapped aliases, env_vars, and dynamic_commands
into ClassificationResult.metadata with no sensitivity checking, unlike
classify_preference()'s existing SENSITIVE_KEY_PATTERNS gate. Shell
aliases and dynamic commands routinely embed credentials as inline text
(e.g. AWS_SECRET_ACCESS_KEY=... in an alias body), and these values route
to Tier NATIVE destinations that a Phase 6 generator will eventually
write into a git-tracked, world-readable /nix/store module.

- Generalize _is_sensitive_key() into _contains_sensitive_pattern() so
  the same substring check covers preference keys, alias/env-var names
  AND values, and full dynamic-command lines.
- Add _redact_sensitive_dict_entries()/_redact_sensitive_list_entries()
  helpers that redact only the matching entry (not the whole field),
  and set metadata["potentially_sensitive_entries_redacted"] when any
  redaction occurs.
- Iterate ENVIRONMENT_MAP directly in classify_shell_setting() instead
  of a separately-hardcoded field-name tuple, so the two can't drift.

Also adds a regression test proving classify_launch_agent()'s raw_plist
cleanup performs a true deep copy (nested dict/list values are not
shared with entry.raw_plist) -- the existing test only used flat values,
which can't distinguish a deep copy from a shallow one.
…e-key gaps, normalize whitespace

- ephemeral_filter: exclude keys ending in a quantity suffix (size, limit,
  count, bytes, length, capacity, max, min, buffer, quota, threshold) from
  the numeric timestamp-range check, since byte counts/limits can coincide
  with the Unix/CFAbsoluteTime ranges (e.g. DiskCacheSize=1073741824)
- scanners/_utils: add _PASSPHRASE and _BEARER to SENSITIVE_KEY_PATTERNS so
  values like DOCKER_..._PASSPHRASE=hunter2 get redacted
- app_to_package: collapse any whitespace run (not just literal spaces) into
  a single hyphen in normalize_app_name, fixing tab/newline pass-through and
  double-space double-hyphen bugs
Deep-copies the shared DEFAULTS_TO_NIX conditions dict before attaching
it to a classification result's metadata, since a shared reference let
one result's mutation corrupt every future lookup. Also fixes the
tier-2 preferences destination check to use Path.is_relative_to()
instead of a naive startswith() string comparison, which could match
sibling directories with a shared prefix.

Broadens sensitive-value detection with a camelCase/delimiter-aware
tokenizer so redaction catches concepts split across boundaries (e.g.
authToken, Bearer ...) in addition to underscore-delimited patterns,
and extends the precheck to scan preference values (recursively for
nested dict/list) in addition to keys. Adds missing app_to_package
entries for Linear.app and Ice.app, which previously fell through to
"unrecognized app" despite being named in the architect plan.
…ro-exit warning

Widens attribute_to_scanner()'s scope in _run_scanner_async to cover the
whole scanner lifecycle (construction, is_available, scan, and exception
handling), not just the scan() call itself. Previously a crashing
scanner's logger.exception() call fired after the attribution context
had already exited, landing the traceback in the unattributed bucket
and duplicating it under "General warnings" alongside the scanner's own
error row.

Also adds a warn_on_nonzero opt-out to run_command() (default True,
preserving existing behavior everywhere else) and applies it to npm's
package-listing call, which routinely exits non-zero on peer-dependency
warnings while still producing valid output on stdout -- a known,
benign case that would otherwise show as a false WARNING status.
…sses

_is_daemon_running() probes both known launchd labels and both known
process names for the nix daemon, since a machine only ever runs one
variant -- checking the other is expected to exit non-zero on every
healthy install, and pgrep follows the same not-a-failure convention
for "no match". Both call sites now opt out of run_command()'s
non-zero-exit warning, matching the existing fix already applied to
npm's equally benign non-zero-exit case.
Rich's Text() only parses its own markup syntax and click.echo() only
strips ANSI on non-tty output -- neither neutralizes literal control
bytes on a real terminal. Since scanner warnings/errors now embed raw
subprocess stderr and exception text, a compromised or malicious local
package's output could inject terminal escape sequences (clear-screen,
cursor movement, OSC title/link spoofing) into the live table or the
general-warnings section. Strips C0 control characters (preserving tab
and newline) at the single point where log records are captured, plus
at the orchestrator's exception-message construction site since that
one bypasses the log-capture path entirely.

Also pops any log records accumulated during a scanner's construction
or is_available() check when it turns out to be skipped, matching the
success/warning/error branches, which already do this.
An exhaustive audit of all 116 run_command() call sites in scanners/
found 18 more instances of the same pattern already fixed for npm and
nix-daemon detection: a non-zero exit that represents a normal,
expected state (feature off, not installed, empty result, no crontab)
rather than a command failure. Left unfixed, these would show as false
ScannerStatus.WARNING in the live table on very common, healthy Mac
configurations -- system_scanner.py alone (always dispatched, not
opt-in) covered screen sharing off, file sharing off, Rosetta not
installed, no printers configured, and not signed into iCloud.
ThreadPoolExecutor.submit() does not copy contextvars into its worker
threads the way asyncio.to_thread() does, so warnings logged inside a
scanner's nested directory-walk pool were losing their scanner
attribution and surfacing as unattributed general warnings instead of
on the correct scanner's row. Copies a fresh context per submitted
item (not one shared across concurrent workers, which raises "context
already entered") so nested pool workers see the same _current_scanner
value as the thread that created them.

Also widens the control-character sanitizer to cover the C1 range
(U+0080-U+009F, including the 8-bit CSI), closing a narrow
defense-in-depth gap on top of the existing C0/DEL stripping.
Same gap as parallel_walk_dirs(): the two ThreadPoolExecutor usages
here (parallel detector dispatch, parallel Go binary inspection) ran
their workers without the calling thread's contextvars, so warnings
from any of the 7 detectors or the go version -m check lost scanner
attribution. Converts the Go inspection loop from pool.map() to
explicit submit() so each item gets its own copy_context() call, same
as detector dispatch. These were the last two ThreadPoolExecutor sites
in the codebase.
…y tally

Distinguishes the two distinct causes of a plist PermissionError per
Apple's own DTS guidance: EACCES means a traditional BSD permission/ACL
denial (e.g. a root-owned, 0600 system file, which Full Disk Access
cannot override), while EPERM means TCC/sandboxing blocked it, which
Full Disk Access genuinely fixes. The "Grant Full Disk Access" hint was
previously shown for the EACCES case -- exactly backwards, since two
real system files (com.apple.apsd.plist, com.apple.wifi.known-networks.plist)
are root-owned 0600 and would never be readable regardless of Full Disk
Access. The EACCES case now gets an accurate, non-actionable hint
instead; the EPERM case is now surfaced (previously silent at DEBUG)
with the Full Disk Access hint it actually deserves.

Also caps the live table's message column width with text folding, so
a single long warning/error line no longer stretches the whole table
edge-to-edge in a wide terminal, and renames the tally's "warning"
label to "completed with warnings" so the summary line doesn't read as
ambiguous about whether a scanner actually finished.
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.

1 participant