Skip to content

feat(sandbox,cli): project a credential-free registry config into the sandbox - #259

Merged
Retr0MrWave merged 4 commits into
mainfrom
feat/registry-config-projection
Aug 28, 2026
Merged

feat(sandbox,cli): project a credential-free registry config into the sandbox#259
Retr0MrWave merged 4 commits into
mainfrom
feat/registry-config-projection

Conversation

@Retr0MrWave

Copy link
Copy Markdown
Contributor

Issue: Closes #150 · Refs #241

What

  • New opt-in profile knob filesystem.registry_config: ["npm"]: omac projects a credential-free copy of ~/.npmrc into the sandbox so scoped packages resolve against their private registry.
  • omac doctor now reports a private registry mapping the sandbox cannot see — the failure that previously had no diagnostic surface anywhere.
  • Unset means today's behaviour: the file stays fully masked.

Why

~/.npmrc is a baseline protected path (internal/sandboxprofile/baseline.go:51) because it commonly holds _authToken. But the same file carries the @scope:registry= mapping, which is load-bearing configuration, not a secret. With it masked, npm resolves a scoped package against the public registry and gets a 404 that reads like "no such package" — no denial event, nothing in omac diagnose, and (per #241) host-side cache purges and --force reinstalls have no effect because they touch paths the sandbox never reads.

The blunt existing workaround, override_deny: ["~/.npmrc"], works but grants the whole file including the token — exactly what #150 set out to avoid.

How

  • Profile knob mirrors the existing network.proxy_injection mechanism: accepted-value list next to ProxyInjectionTools(), rejected-if-unknown in Profile.Validate().
  • New internal/registryconf does the scrub. Only registry-mapping keys survive; a kept value must resolve to a credential-free http(s) URL. npm's own value syntax is honored first (quotes stripped, ${VAR} expanded) so a mapping npm would act on is not lost.
  • internal/sandboxrun/registryconf.go writes the copy per launch, appends only that file to Grants.ReadPaths, and injects NPM_CONFIG_USERCONFIG through the same injected env map as ProxyInjectionEnv.
  • The doctor check reads the filesystem, so it deliberately does not live in profileaudit.Check (documented I/O-free) — it sits alongside the other doctor checks.

Two things are refused rather than projected, both reported at launch and by doctor:

  • A URL with a query string or fragment — that is where an API key usually hides (?apiKey=…) and omac cannot tell a secret parameter from a load-bearing one.
  • The global registry key when the file holds a credential for that host: redirecting all resolution to a registry omac cannot authenticate to would break even the public installs that work today. A scoped mapping to such a host is kept (that scope was already failing) with a 401/403 warning.

Note: the file-side detector uses "host is not npm's default" rather than netproxy's isPackageRegistry heuristic, which returns false for exactly the corporate shape this exists for (tng-artifacts.int.tngtech.com has no registry/npm label) — verified before relying on it.

Verification

go build ./..., go vet ./internal/... clean. go test ./... passes except TestIntegrationWorktreeKnownLimitations and TestIntegrationWorkflowInterpretersRunnable, which fail identically on clean main on this host (local toolchain paths absent from the default profile).

End-to-end against #241's actual case, Linux/bwrap, one binary, cold cache, no override_deny:

knob off -> 0 skainet models   (only ALLOW registry.npmjs.org — the 404 path)
knob on  -> 14 skainet models, installs @tngtech/opencode-skainet 1.0.3
            ALLOW tng-artifacts.int.tngtech.com:443, ALLOW chat.model.tngtech.com:443

Inside the sandbox with the knob on:

cat ~/.npmrc               -> No such file or directory   (host file still masked)
cat $NPM_CONFIG_USERCONFIG -> @tngtech:registry=https://tng-artifacts.int.tngtech.com

omac doctor on a profile without the knob names the cause unprompted:

[warn] registry config: /home/…/.npmrc maps a scope to tng-artifacts.int.tngtech.com,
       but the sandbox cannot read it
       Scoped installs will fail with a 404 against the public registry. Fix:
       add filesystem.registry_config: ["npm"] to the sandbox profile.

The second commit is review fixes; each finding has a regression test (query-string leak, npm value syntax, unauthenticated global registry, non-fatal read failure, doctor reporting both projection and override).

Follow-up

🤖 Generated with Claude Code

Ilia Zhuravok and others added 2 commits August 24, 2026 11:47
… sandbox

A scoped package resolves through a mapping in ~/.npmrc
(@acme:registry=...). That file is a protected path because it also
commonly holds an auth token, so inside the sandbox npm falls back to the
public registry and 404s — a failure that reads like "no such package",
leaves no denial event, and is invisible to `omac diagnose`.

filesystem.registry_config: ["npm"] opts a profile into a projection:
omac derives a copy of ~/.npmrc holding only registry mappings, grants
read access to that copy alone, and points npm at it via
NPM_CONFIG_USERCONFIG. The host file stays masked, so unlike
override_deny no credential is exposed. Unset keeps today's behavior.

`omac doctor` now reports a private mapping the sandbox cannot see, and
warns when override_deny is doing a job the projection does without the
exposure.

Verified on Linux/bwrap against the case in #241, one binary, cold cache:
  knob off -> 0 skainet models (only ALLOW registry.npmjs.org)
  knob on  -> 14 skainet models, installs @tngtech/opencode-skainet 1.0.3
              ALLOW tng-artifacts.int.tngtech.com, ALLOW chat.model.tngtech.com
  inside the sandbox, with the knob on:
              cat ~/.npmrc              -> No such file or directory
              cat $NPM_CONFIG_USERCONFIG -> the mapping line only
  omac doctor -> "[warn] registry config: ~/.npmrc maps a scope to
              tng-artifacts.int.tngtech.com, but the sandbox cannot read it"

go build ./... && go test ./... pass except TestIntegrationWorktreeKnownLimitations
and TestIntegrationWorkflowInterpretersRunnable, which fail identically on
clean main on this host (local toolchain paths absent from the default profile).

Closes #150
Refs #241

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Ilia Zhuravok <ilia.zhuravok@tngtech.com>
…y projection

Review findings on this branch. Two of them broke stated guarantees; both
were confirmed empirically before fixing.

Secret leak. registryURL stripped only URL userinfo, so a secret carried
anywhere else survived into the sandbox-readable file, contradicting the
package's "no credential can survive by construction" invariant and the
docs. Verified: `@acme:registry=https://npm.acme.test/api/?apiKey=SECRET`
was projected verbatim (Dropped=0, StrippedUserinfo=0), and the
never-leaks test passed because it only asserted u.User == nil. A URL with
a query string or fragment is now refused outright rather than stripped:
that is where an API key usually hides, and omac cannot tell a secret
parameter from a load-bearing one.

Regression risk. The global `registry` key was projected even when the
file held a credential for that host, pointing npm at a private mirror it
cannot authenticate to — which breaks every install, including the public
dependencies that work today with the file fully masked. Credential keys
(`//host/:_authToken`) are now correlated with mapping hosts: a global
mapping needing auth is refused, a scoped one is kept (that scope was
already failing) with a warning that installs may 401.

Also fixed:

- Values npm accepts but url.Parse rejects were silently skipped, and
  because InspectNPM reads the scrubbed output, doctor went silent too —
  leaving exactly the unexplained 404 this feature exists to prevent.
  npm's value syntax is now honored first (surrounding quotes removed,
  ${VAR} expanded), and anything still unusable is reported as a rejection
  at launch and by doctor instead of vanishing.
- A non-ENOENT failure reading ~/.npmrc aborted the launch (unresolvable
  HOME, or EACCES after a root-owned `sudo npm config set`). An opt-in
  convenience that only ever adds a mapping now degrades to a warning.
- doctor checked Enabled before Overridden, so a profile with both
  registry_config and override_deny got only "[ok] … projected" and was
  never told the token-bearing file is still readable. Each condition is
  now reported independently.
- docs: document the refusals, npm value syntax, and the scoped-vs-global
  auth distinction.

Verified the #241 acceptance test is unaffected by the stricter scrub:
cold cache, knob on -> 14 skainet models, plugin 1.0.3, and doctor still
names the cause on a profile without the knob.

Refs #150
Refs #241

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Ilia Zhuravok <ilia.zhuravok@tngtech.com>
Comment thread internal/registryconf/registryconf.go Outdated
u.User = nil
stripped = true
}
return u.String(), u.Hostname(), stripped, ""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[MUST FIX] bug, security, missing-test

registryURL returns u.Hostname() (port stripped), but credentialHost (line 401) keeps host:port, so a port-scoped credential never correlates with its mapping:

registry=https://npm.acme.test:8443
//npm.acme.test:8443/:_authToken=T

yields kept=[registry], needsAuth=[] — the global registry to a credentialed host is projected, and the scoped mapping to that host silently loses its NeedsAuth warning. This is the regression eae1299 claims to close; its regression test only covers the portless shape.

Suggested change
return u.String(), u.Hostname(), stripped, ""
return u.String(), u.Host, stripped, ""

Correlating on u.Host is contained — the host feeds only keptHosts correlation and rejection message text. Add the port pair (global refused, scoped flagged) to TestScrubNPMRCRefusesUnauthenticatedGlobalRegistry.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed, and confirmed the shape you gave was projected before the fix.

Took u.Host as suggested, but routed both sides through one normalizeHost
helper rather than only changing the return, because the same comparison had a
second break: credentialHost lowercased and u.Hostname() did not, so
registry=https://NPM.Acme.Test + //npm.acme.test/:_authToken=T also failed
to correlate. Normalization is lowercase + strip an explicit default port
(npm's nerfDart does the same), so https://host:443 now correlates with
//host/:_authToken too.

Tests: TestCorrelationMatchesPortScopedCredential (your exact pair, global
refused and the scoped variant flagged), TestCorrelationIsCaseInsensitive,
TestCorrelationNormalizesDefaultPort, and TestCorrelationIgnoresUnrelatedHost
for the flip side.

Comment thread internal/registryconf/registryconf.go Outdated
v = v[1 : len(v)-1]
}
}
return os.ExpandEnv(v)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[SHOULD FIX] security, bug, docs-drift

os.ExpandEnv expands from the full host env, and an expanded secret survives into the sandbox-readable projection in a URL path position: @acme:registry=https://npm.acme.test/api/${SECRET}/npm/ with SECRET=LIVESECRET projects …/api/LIVESECRET/npm/. Query/fragment are refused for exactly this reason; path is not, so the package invariant "no credential can survive by construction" does not hold.

Refusing expanded values outright would regress the corporate ${ART_HOST} shape the expansion exists for. Suggested: when expansion actually modified the value, emit a launch/doctor warning of the same class as Rejected ("mapping was expanded from ${…}; verify it carries no secret"), and document the residual path-position risk next to the query/fragment refusal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed, and I went stricter than your suggestion: a placeholder outside the URL's
authority is refused, not warned about.

Reasoning: a warning leaves the package's stated invariant conditional on
someone reading it, and we already refuse a query string for exactly this
"cannot tell a secret from a load-bearing value" reason — so warning here would
be inconsistent with the neighbouring rule. Your concern about regressing
${ART_HOST} is addressed by keeping expansion in the authority:

@acme:registry=https://${ART_HOST}/api/npm/npm/   -> projected
@acme:registry=https://host:${PORT}/npm/          -> projected
@acme:registry=https://${U}:${PW}@host/npm/       -> expands, then userinfo stripped
@acme:registry=https://host/api/${SECRET}/npm/    -> refused, names SECRET

Mechanism: split the raw template at the authority boundary (find "://", then
the first /, ? or #) — structural, because an unexpanded template usually
does not parse as a URL. The authority goes through os.ExpandEnv; the
remainder goes through os.Expand with a mapper that only records that a
placeholder was consumed. So the refusal decision is made by os.Expand's own
tokenizer (${NAME}, $NAME, $$ cannot drift from the expansion path), and
no expanded value is ever matched against the URL — no coincidental-substring
false positives. TestExpansionAllowedInAuthorityOnly covers each position,
including a literal $ in the path that must not trigger a refusal.

Docs and the package doc updated: the invariant now reads as "userinfo removed;
query, fragment and non-authority interpolation refused", which is what the code
actually enforces.

Comment thread internal/registryconf/registryconf.go Outdated

// credentialKey matches npmrc keys that carry authentication material,
// including the per-registry form "//host/path/:_authToken".
var credentialKey = regexp.MustCompile(`(?i)(_auth|_authtoken|_password|username|email|^//)`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[NICE TO HAVE] bug, docs-accuracy

The ^// alternative counts //npm.acme.test/:always-auth=true as credential material (DroppedCredentials++), so doctor prints "including the auth token it holds" for a file holding only a boolean flag. Restrict the classification to _auth|_authtoken|_password variants; treat other //host/: keys as dropped-but-not-credential.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. Classification now matches the key's leaf (after the last :)
against _auth|_authtoken|_password, so //host/:always-auth=true is dropped
like any other non-mapping key but no longer counted, and no longer marks the
host as needing auth. username/email are also excluded — they are dropped,
but they are not tokens and counting them overstated what a projection
protects. TestHostScopedNonCredentialIsNotCountedAsCredential.

// credentialHost extracts the registry host from a per-registry auth key
// such as "//npm.acme.test/:_authToken" or "//npm.acme.test/api/:_password".
// Returns "" for keys that are not host-scoped (e.g. a bare "_auth").
func credentialHost(key string) string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[NICE TO HAVE] decision-conflict

Host-less credentials (_auth=…, _password=…) return "" here and never correlate with the global registry mapping — registry=https://npm.acme.test + _auth=BASE64SECRET is kept with no NeedsAuth flag. That contradicts the stated refusal rationale ("the file held a credential for that host"), and eae1299's message claims credential correlation that only holds for //host/-scoped keys. Pin the chosen semantics in a comment + test so this doesn't get re-filed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed by choosing the semantics rather than only documenting them: npm applies
legacy host-less _auth/_password to the default registry, so they now
bear on the global registry mapping (refused, same as the //host/-scoped
case). A scoped mapping is unaffected — a host-less credential says nothing
about a scope's own registry.

Pinned by comment and by TestHostlessCredentialAppliesToGlobalRegistry, which
asserts both halves so the alternative reading cannot be re-filed silently.

authHosts := map[string]bool{}

for _, raw := range strings.Split(string(src), "\n") {
line := strings.TrimSpace(raw)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[NICE TO HAVE] bug

A UTF-8-BOM npmrc (plausible for Windows-authored files) has its leading @scope:registry=… counted as Dropped rather than Rejected: hosts=[], Rejected=[]InspectNPM returns nil → neither launch nor doctor says anything — the silent 404 this feature exists to prevent. Trim a leading BOM once, or report leading garbage as Rejected.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: one leading BOM is trimmed at the top of ScrubNPMRC. Good catch on the
consequence — it was not just a mis-count, it landed in Dropped where
InspectNPM returns nil, so neither the launch path nor doctor said anything,
which is precisely the silent 404 this feature exists to prevent.
TestScrubHandlesBOM. (TestScrubHandlesCRLF pins the CRLF behaviour you
noted as correct-but-untested, including that the CR does not defeat
correlation.)

Comment thread internal/registryconf/registryconf.go Outdated
// keptHosts maps a kept mapping key to its registry host, so the
// credential correlation below can run after the whole file is read
// (auth lines may appear before or after the mapping they apply to).
keptHosts := map[string]string{}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[NICE TO HAVE] maintainability

Duplicate mapping keys project twice, and the correlation loop pairs res.KeptKeys[i] with lines[i] by index across a re-filtering pass — a future continue between the two append sites (lines 341-342 vs 368-369) desyncs them silently. Carrying mapping{key, host, line} in one slice (with explicit last-wins) removes both hazards.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed as suggested: one []mapping{key, host, line} replaces the parallel
slices, so nothing can desync, and duplicates are resolved explicitly with
last-wins (npm's ini semantics) instead of being projected twice.
TestDuplicateKeyLastWins.

// Grant exactly the projected file, read-only. The host file is
// untouched and stays protected.
grants.ReadPaths = append(grants.ReadPaths, p.Path)
injected[p.EnvVar] = p.Path

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[NICE TO HAVE] backwards-compat

A profile that both allowlists NPM_CONFIG_USERCONFIG and opts into the projection silently gets the projection instead — injected wins, and the user's other settings in their config are dropped. Emit a one-line warning when injected already contains NPM_CONFIG_USERCONFIG.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: warns when the variable is already set to a different path, naming the
path whose settings the sandbox will not see. The projection still takes
precedence — the warning explains rather than defers, since silently honouring
the user's file would reintroduce the unmasked-config problem. Tests for both
the warning and the quiet ordinary path.

Comment thread internal/cli/doctor.go
}
enabled := slices.Contains(profile.Filesystem.RegistryConfig, sandboxprofile.RegistryConfigNPM)
src, err := registryconf.NPMUserConfig()
if err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[NICE TO HAVE] bug, consistency

The launch path turns a non-ENOENT read failure on ~/.npmrc (e.g. EACCES after a root-owned sudo npm config set) into a projection warning (registryconf.go:143-145), but doctor swallows the identical error and prints nothing. Report it as [warn] or reuse the projector's Warning path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: reported as [warn] with the consequence spelled out, matching the
launch path. Worth flagging that doctor is the only check that runs before a
launch, so this silence was the more costly of the two.
TestDoctorRegistryConfigReportsUnreadableConfig.

// Unset means the historical behavior: the file stays fully masked.
// The blunt alternative — override_deny on ~/.npmrc — grants the
// whole file including any auth token; this does not.
RegistryConfig []string `json:"registry_config,omitempty"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[NICE TO HAVE] docs-drift, backwards-compat

Adding this field means an older omac binary hard-rejects any shared/base profile that gains registry_config with a bare json: unknown field "registry_config" (strict decode at profile.go:301) — no hint that a newer omac is needed, so upgrading one machine breaks every other machine's profile. Consider a known-field hint in the parse-error path, and note the forward-incompatibility in CONFIGURATION.md.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both parts done. CONFIGURATION.md gains a note that profiles are parsed
strictly and a profile using registry_config is rejected by an older omac, so
shared/checked-in profiles want the upgrade first. And the unknown-field branch
of Parse now suggests which direction to look ("if this profile was written
for a newer omac, upgrade omac; otherwise remove the field") while other parse
errors keep the plain message. Strictness is unchanged — a typo must still fail
loudly. TestUnknownFieldErrorHintsAtVersionSkew asserts both the hint and that
it does not leak onto unrelated parse errors.

Note this only helps from this version forward; already-released binaries will
still emit the bare message. That is why the doc note matters more than the code
change here.

@NoRiceToday

Copy link
Copy Markdown
Contributor

Adversarial review — verdict: fix-then-ship, risk: medium. Findings 1-9 inline; P1/P2 confirmed empirically against the real scrubber.

Test gaps (nice to have): the P1/P2 both slipped through because there is no port-scoped credential case and no post-condition pinning that env-expanded values are scrutinized after expansion. Also missing: duplicate-key, BOM, and CRLF cases (CRLF currently correct but untested), a pre-set NPM_CONFIG_USERCONFIG case, a symlinked ~/.npmrc case, and Windows USERPROFILE staging (tests use t.Setenv("HOME"), which os.UserHomeDir does not read on Windows).

@Retr0MrWave
Retr0MrWave removed the request for review from nhuelstng August 27, 2026 13:49
Adversarial review of #259 returned nine findings. All are addressed here;
the two substantive ones broke guarantees the PR states.

P1 (MUST). registryURL returned u.Hostname() — port stripped — while
credentialHost returned a lowercased host:port, so a port-scoped credential
never correlated with its mapping:

  registry=https://npm.acme.test:8443
  //npm.acme.test:8443/:_authToken=T

projected the global registry without the token it needs — exactly the
regression eae1299 claimed to close. A second facet the review did not
mention: credentialHost lowercased and u.Hostname() did not, so a mixed-case
mapping host escaped correlation too. Both sides now normalize through one
helper (lowercase, strip an explicit default port, matching npm's own
nerfDart keying). Ports are stripped regardless of scheme deliberately: a
credential key carries no scheme, and any asymmetry there risks
under-correlating, which silently reopens this hole.

P2 (SHOULD). npmValue called os.ExpandEnv on the whole value, so a secret
could be interpolated into a URL path — a position neither stripped like
userinfo nor refused like a query string — making the package's "no
credential can survive by construction" claim false. Expansion is now split
at the authority boundary: the authority expands normally (the corporate
${ART_HOST} shape keeps working), and if the remainder consumes any
placeholder the mapping is refused with the variable named. The refusal
decision is made by os.Expand itself, so the placeholder syntax cannot drift
from the syntax the authority expansion uses, and no expanded *value* is ever
matched against the URL — so there are no coincidental-substring refusals.

Also fixed:

- `//host/:always-auth=true` was classified as credential material via a bare
  `^//`, making doctor claim the file "holds an auth token" for a boolean.
  Classification now matches on the key's leaf against _auth/_authtoken/
  _password only.
- Host-less legacy credentials (`_auth`, `_password`) never correlated, so
  `registry=…` + `_auth=…` was projected unflagged, contradicting the stated
  refusal rationale. npm applies those to the default registry, so they now
  bear on the global mapping; scoped mappings are unaffected. Semantics
  pinned by comment and test.
- A UTF-8 BOM glued itself to the first key, landing the mapping in Dropped
  where neither the launch path nor doctor reports anything — the silent 404
  this feature exists to prevent. Trimmed once.
- KeptKeys and lines were parallel slices paired by index across a
  re-filtering pass, which any future `continue` would desync silently, and
  duplicate keys projected twice. Replaced by one []mapping with explicit
  last-wins (npm ini semantics).
- A pre-set NPM_CONFIG_USERCONFIG was silently overridden by the projection,
  dropping the user's own config. Now warned, naming the overridden path.
- doctor swallowed a non-ENOENT read failure on ~/.npmrc that the launch path
  warns about, leaving the one check that runs *before* a launch silent.
- Strict profile decoding rejects a newer profile with a bare "unknown
  field", giving no clue about version skew. The unknown-field case now says
  which direction to look; other parse errors are unchanged. Forward
  incompatibility documented in CONFIGURATION.md.
- Package doc and CONFIGURATION.md restated to match what the code
  guarantees (authority-only expansion, query/fragment refused).

Tests: port-scoped, mixed-case, default-port and unrelated-host correlation;
expansion in host/port/userinfo/path positions plus a literal `$`; boolean
host-scoped key; host-less credential for global vs scoped; BOM; CRLF;
duplicate keys; symlinked ~/.npmrc; pre-set env var; doctor read failure;
unknown-field hint. The HOME test helper also sets USERPROFILE — not for
Windows support (.goreleaser.yaml builds linux and darwin only), just so the
helper does not lie about what it stages.

Verified the acceptance test is unaffected by the stricter scrubber:
  cold cache, knob on, no override_deny -> 17 skainet models, plugin 1.0.3
  inside the sandbox: cat ~/.npmrc -> No such file or directory
                      cat $NPM_CONFIG_USERCONFIG -> the mapping line only
                      grep -c "_auth|_password|apiKey" -> 0
go build ./..., go vet ./internal/... clean; go test ./... passes except the
two integration tests that fail identically on clean main on this host and
pass in CI.

Refs #150
Refs #241

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Ilia Zhuravok <ilia.zhuravok@tngtech.com>
@Retr0MrWave

Copy link
Copy Markdown
Contributor Author

Thanks — this was a genuinely useful review. All nine findings are fixed in
c804778; each has a regression test. Two notes on where I went further or
narrower than suggested:

  • P1 had a second facet. Beyond the port mismatch, credentialHost
    lowercased its host while u.Hostname() did not, so a mixed-case mapping
    (registry=https://NPM.Acme.Test) also escaped correlation. Both sides now
    normalize through one helper: lowercase plus strip an explicit default port,
    matching npm's own nerfDart keying. Default ports are stripped regardless of
    scheme deliberately — a credential key carries no scheme, so a scheme-aware
    rule could only apply to one side, and any asymmetry there risks
    under-correlating, which silently reopens the hole. The one inaccuracy
    that accepts (http://host:443 matching a //host/ credential) fails safe:
    it refuses a mapping and says why.
  • P2 is refused rather than warned. Rationale below.

On the test-gap list: added port-scoped, mixed-case, default-port and
unrelated-host correlation; expansion in host/port/userinfo/path positions plus
a literal $; boolean host-scoped key; host-less credential for global vs
scoped; BOM; CRLF; duplicate keys; symlinked ~/.npmrc; pre-set
NPM_CONFIG_USERCONFIG; doctor read failure; and the unknown-field hint.

Windows staging: deliberately not added. You are right that
os.UserHomeDir ignores HOME there, but .goreleaser.yaml builds linux
and darwin only, so there is no Windows target to protect and Windows test
scaffolding would be dead weight. The helper now sets USERPROFILE alongside
HOME — one line, so the helper does not misrepresent what it stages if that
ever changes.

@NoRiceToday NoRiceToday left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM now, thanks for addressing the feedback :) Just take care of the conflicts then you can merge

main rewrote the documentation (3187b34 "Remove old docs" and the commits
around it), so docs/CONFIGURATION.md and docs/SECURITY_MODEL.md no longer
exist. Both were modified on this branch, which is the whole conflict; the
Go changes merged cleanly.

Resolution: accept the deletion and port this branch's two doc additions
into the new structure, rewritten to match its shorter, task-oriented style
rather than moved verbatim.

- docs/configuration.md: a `filesystem.registry_config` row in the sandbox
  grants field table, and a "Private package registries" subsection next to
  "Java and Node dependency downloads", which is the closest existing
  analogue (same shape of problem, same neighbourhood).
- docs/configuration.md: one sentence added to the existing paragraph about
  upgrades, covering the reverse direction (an older omac rejects a profile
  that uses a newer field). It replaces the standalone note this branch had,
  because that paragraph already sets up the context.
- docs/security.md: a `~/.npmrc` row in the sandbox access reference table,
  pointing at the configuration section.

Checked that both sides survived the automatic merge of the two Go files:
setupRegistryConfig is still wired into sandboxrun.Run, doctorRegistryConfig
into runDoctor, and main's skillstate/skillconfig rework of doctor plus its
reworded allow_vars warning are intact.

Verified after the merge: go build ./... and go vet ./internal/... clean;
go test ./... passes except TestIntegrationWorktreeKnownLimitations and
TestIntegrationWorkflowInterpretersRunnable, which fail identically on a
clean origin/main worktree on this host and pass in CI. End to end with the
merged binary, cold cache, knob on, no override_deny: 16 skainet models,
plugin 1.0.3 installed, ~/.npmrc still unreadable inside the sandbox, and
the projected file holding only the mapping line.

Refs #150
Refs #241

Signed-off-by: Ilia Zhuravok <ilia.zhuravok@tngtech.com>
@Retr0MrWave
Retr0MrWave merged commit 11c9b46 into main Aug 28, 2026
11 checks passed
@Retr0MrWave
Retr0MrWave deleted the feat/registry-config-projection branch August 28, 2026 10:28
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.

feat(sandbox): support & detect private-registry auth (protected ~/.npmrc)

2 participants