Skip to content

fix(security): harden P0 code scanning boundaries#4878

Merged
cv merged 7 commits into
mainfrom
fix/security-p0-code-scanning
Jun 6, 2026
Merged

fix(security): harden P0 code scanning boundaries#4878
cv merged 7 commits into
mainfrom
fix/security-p0-code-scanning

Conversation

@cv

@cv cv commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Harden the P0 code-scanning buckets from #3654 by tightening process spawn boundaries, HTTP/file probe boundaries, and filesystem race-prone IO paths. The changes keep runtime behavior intact while adding validation, atomic writes, descriptor-pinned reads, and regression coverage for the high-priority alert classes.

Related Issue

Refs #3654

Changes

  • Force runner process helpers onto argv-only, shell: false spawn boundaries and reject NUL bytes before spawning.
  • Replace uninstall command discovery's shell-based command -v check with direct PATH probing.
  • Validate curl probe URLs, reject file-reading curl options for probe requests, and restrict Ollama HTTP pulls to known local Ollama hosts.
  • Convert selected state/config/credential writes and lock reads to atomic or descriptor-pinned IO paths.
  • Add or update tests for runner argv validation, curl probe URL/file-read rejection, credential symlink handling, onboard lock race behavior, and blueprint state atomic writes.

Type of Change

  • Code change (feature, bug fix, or refactor)
  • Code change with doc updates
  • Doc only (prose changes, no code sample modifications)
  • Doc only (includes code sample changes)

Verification

  • npx prek run --all-files passes
  • npm test passes
  • Tests added or updated for new or changed behavior
  • No secrets, API keys, or credentials committed
  • Docs updated for user-facing behavior changes
  • npm run docs builds without warnings (doc changes only)
  • Doc pages follow the style guide (doc changes only)
  • New doc pages include SPDX header and frontmatter (new pages only)

Note: npm test was attempted, but the existing installer integration test test/install-preflight.test.ts > warns on Podman but still runs onboarding fails in this environment because host CDI detection turns the Podman warning path into a missing-CDI issue path before onboarding. Targeted tests and npx prek run --all-files pass.


Signed-off-by: Carlos Villela cvillela@nvidia.com

Summary by CodeRabbit

  • New Features

    • Stronger probe validation with trusted curl-config option; safer, normalized probe invocations.
    • Atomic, permissioned writes for state and credential files; inode-snapshot lock handling for onboarding.
  • Bug Fixes

    • Hardened process spawning and argv validation (reject NULs, enforce no-shell execution).
    • Safer file operations: no-follow opens, conditional unlinking, improved shim detection and known_hosts pruning, better home resolution.
  • Tests

    • Expanded coverage for curl validation, NUL-byte rejection, symlink-safe sanitization, uninstall shim scenarios, and lock races.

@cv cv added the security Potential vulnerability, unsafe behavior, or access risk label Jun 6, 2026
@cv cv self-assigned this Jun 6, 2026
@coderabbitai

coderabbitai Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Wondering what really moved? Review this PR in Change Stack to inspect semantic changes, definitions, and references.

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Centralized argv validation and NUL-byte rejection; atomic temp-file writes for state and credential sanitization; symlink-resistant descriptor-based file operations; curl-probe argv validation with trusted-config allowlist; lock-file snapshot inode checks; related tests updated.

Changes

Security hardening: atomic writes, symlink-safe file ops, and argv validation

Layer / File(s) Summary
Argv validation and NUL-byte rejection in spawning
src/lib/runner.ts, src/lib/runner-argv.test.ts
normalizeArgv and helpers reject NUL bytes and empty executables; spawn entry points normalize inputs and enforce shell: false. Tests added for NUL-byte rejection.
Atomic file write patterns
nemoclaw/src/blueprint/state.ts, nemoclaw/src/blueprint/state.test.ts, src/lib/security/credential-filter.ts, src/lib/security/credential-filter.test.ts
Introduces atomic temp-file write helpers (PID+UUID temp file, mode 0o600, write+rename) used by state persistence and credential sanitization; tests/mocks updated to include renameSync behavior and clear-state now always overwrites via atomic writes.
Symlink-safe descriptor workflows and FS deps
src/lib/actions/uninstall/plan.ts, src/lib/actions/uninstall/run-plan.test.ts, src/lib/credentials/store.ts, src/lib/actions/uninstall/run-plan.ts
FileSystemDeps expanded with openSync/fstatSync/closeSync; classifyShimPath, secureUnlink, and credential staging use O_NOFOLLOW or lstat fallbacks and close descriptors reliably; tests simulate ELOOP and symlink-loop behaviors.
Lock file snapshot reading with inode validation
src/lib/state/onboard-session.ts, src/lib/state/onboard-session.test.ts
Adds LockFileSnapshot and readLockFileSnapshot() to read lock files via fd and fstat (bigint inode, mtimeMs); acquire/release use snapshots and unlinkIfInodeMatches() to avoid TOCTOU races; tests adjusted for new stat timing.
Curl probe argument validation and trusted config paths
src/lib/adapters/http/probe.ts, src/lib/adapters/http/probe.test.ts, src/lib/inference/health.ts, src/lib/messaging/channels/slack/*
Implements URL normalization (http/https, no embedded creds), forbids curl options that read local files and @file request bodies, enforces URL as final argv entry, adds trustedConfigFiles allowlist, and wires the option into probe usages with tests.
Ollama model normalization and command-existence changes
src/lib/inference/ollama/proxy.ts, src/lib/actions/uninstall/run-plan.ts
Adds model-id validation and constrained local-pull URL builder for Ollama HTTP pulls; replaces shell-based command-exists with PATH enumeration + fs.accessSync(X_OK) and changes home fallback to os.homedir().
SSH known_hosts cleanup simplification
src/lib/actions/sandbox/gateway-state.ts
Removes explicit existsSync guard and performs known_hosts read/prune/write inside a single try/catch for best-effort cleanup.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • NVIDIA/NemoClaw#4248: Overlaps Slack trustedConfigFiles plumbing and probe handling changes.
  • NVIDIA/NemoClaw#4582: Related hardened runCurlProbe handling and trustedConfigFiles wiring used by Slack credential validation.

Suggested labels

area: security, bug-fix

Suggested reviewers

  • prekshivyas
  • cjagwani

"A rabbit hops through code at night,
temp files tucked and permissions tight,
NULs are barred, symlinks kept at bay,
atomic renames guard state away.
I nibble tests and hop — all clear and bright."

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'fix(security): harden P0 code scanning boundaries' directly corresponds to the main objective stated in PR #4878: to harden P0 code-scanning findings by tightening process spawn boundaries, HTTP/file probe boundaries, and filesystem race-prone IO paths.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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/security-p0-code-scanning

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

@cv cv added the v0.0.61 Release target label Jun 6, 2026
@cv cv requested review from cjagwani and prekshivyas June 6, 2026 00:08
Comment thread src/lib/actions/uninstall/plan.ts Fixed
Comment thread src/lib/actions/uninstall/plan.ts Fixed
Comment thread src/lib/adapters/http/probe.ts Fixed
Comment thread src/lib/adapters/http/probe.ts Fixed
Comment thread src/lib/adapters/http/probe.ts Fixed
@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

E2E Advisor Recommendation

Required E2E: credential-migration-e2e, credential-sanitization-e2e, inference-routing-e2e, onboard-inference-smoke-e2e, messaging-providers-e2e, ollama-proxy-e2e, cloud-onboard-e2e, sandbox-operations-e2e, gateway-drift-preflight-e2e
Optional E2E: cloud-inference-e2e, kimi-inference-compat-e2e, openclaw-inference-switch-e2e, state-backup-restore-e2e, gpu-double-onboard-e2e, channels-add-remove-e2e

Dispatch hint: cloud-onboard-e2e,credential-migration-e2e,credential-sanitization-e2e,inference-routing-e2e,messaging-providers-e2e,sandbox-operations-e2e

Auto-dispatched E2E: credential-migration-e2e, credential-sanitization-e2e, inference-routing-e2e, messaging-providers-e2e, cloud-onboard-e2e, sandbox-operations-e2e via nightly-e2e.yaml at 3ddae224b463a30711d199f40f848fc5a475cbc8nightly run

Workflow run

Full advisor summary

E2E Recommendation Advisor

Base: origin/main
Head: HEAD
Confidence: high

Required E2E

  • credential-migration-e2e (medium): Directly covers legacy ~/.nemoclaw/credentials.json migration to gateway credentials, secure removal, allowlist filtering, and symlink-safe handling changed in src/lib/credentials/store.ts.
  • credential-sanitization-e2e (medium-high): Required because credential-filter and credential-store changes affect the sandbox secret boundary and runtime leak prevention. This E2E validates credential stripping from snapshots/state, auth-profile cleanup, symlink traversal protections, and runtime sandbox credential checks.
  • inference-routing-e2e (medium): Required for changes to curl probe validation, inference health, and credential isolation in inference paths. It exercises routing through the live sandbox and verifies API key absence from sandbox env/process/files plus classified invalid-key/unreachable-endpoint failures.
  • onboard-inference-smoke-e2e (low-medium): Required because provider health/probe behavior and onboarding session/runner code changed. This regression verifies onboard does not report success until a configured inference route has served a real chat-completions request and surfaces actionable diagnostics on runtime-broken routes.
  • messaging-providers-e2e (high): Required for Slack credential-validation and HTTP probe config changes. This E2E covers Telegram/Discord/Slack provider creation, credential placeholder isolation, OpenShell alias resolution, and hermetic fake Slack auth.test/apps.connections.open paths.
  • ollama-proxy-e2e (medium): Required because src/lib/inference/ollama/proxy.ts changed local Ollama pull URL/model validation and the pull path uses curl against the local daemon. This workflow validates the auth proxy end-to-end with real Ollama, real inference, token auth, persistence, recovery, and container reachability.
  • cloud-onboard-e2e (medium-high): Required broad real-user onboarding coverage for runner, onboard-session, credential staging, provider health, sandbox creation, and source install interactions touched by this PR.
  • sandbox-operations-e2e (high): Required because gateway-state changes affect live sandbox verification and recovery behavior. This job exercises real sandbox operations through the CLI against an onboarded sandbox.
  • gateway-drift-preflight-e2e (low-medium): Required nearest existing coverage for gateway-state drift/fail-closed behavior. The PR changes identity-drift recovery and host-key cleanup in the same reconciliation path used when gateway/sandbox state is stale or inconsistent.

Optional E2E

  • cloud-inference-e2e (medium): Useful confidence check for real NVIDIA endpoint reachability after curl probe and inference health changes, but inference-routing-e2e and onboard-inference-smoke-e2e are the more targeted blockers.
  • kimi-inference-compat-e2e (medium-high): Kimi K2.6 health now passes trusted curl config files through the probe. This is valuable Kimi-specific coverage, but may be optional if the targeted probe/unit tests and inference-routing-e2e pass.
  • openclaw-inference-switch-e2e (high): Adjacent coverage for live inference route changes and persisted OpenClaw config after switching providers. Helpful because HTTP probes and health classification changed.
  • state-backup-restore-e2e (high): Optional confidence for stateful sandbox persistence/recovery after atomic blueprint state writes and onboard-session state changes. It is broader than the exact changed code but catches user-visible state regressions.
  • gpu-double-onboard-e2e (very-high): Optional expensive GPU coverage for local Ollama re-onboard/proxy token consistency after Ollama proxy changes. Run if the PR is high-risk for local GPU users.
  • channels-add-remove-e2e (high): Optional adjacent coverage for Slack credential reuse through channel lifecycle and rebuild paths. Messaging-providers-e2e is the stronger required check for this PR.

New E2E recommendations

  • uninstall-security (high): The PR changes uninstall shim classification, no-follow open/fstat/read behavior, HOME fallback, and PATH-based command discovery, but existing E2Es only cover uninstall indirectly in GPU flows. Add a PR-safe host-side E2E that creates real files/symlinks/FIFOs under a temp HOME, runs uninstall planning/execution, and asserts symlink targets are not followed or removed.
    • Suggested test: test/e2e/test-uninstall-security.sh with a workflow-dispatch job uninstall-security-e2e
  • curl-probe-security (medium): The curl probe now blocks non-http URLs, file-backed headers/body/config/options, and multi-transfer args. Unit tests cover helpers, but a real CLI/onboard path E2E should verify malicious provider/health probe inputs are rejected before spawning curl and without reading local files.
    • Suggested test: Add a focused curl-probe-security-e2e that exercises runner/onboard/inference paths with malicious curl argv and checks no local file read occurs.
  • gateway-identity-drift (medium): The changed gateway-state branch specifically prunes known_hosts entries and retries after SSH identity drift. Existing gateway-drift-preflight coverage is nearby but does not appear to prove the known_hosts cleanup/reconnect path.
    • Suggested test: Add gateway-identity-drift-known-hosts-e2e that seeds stale known_hosts entries, simulates/recreates gateway identity drift, and verifies reconnect succeeds after pruning.
  • slack-credential-validation (medium): Slack validation now relies on trusted temporary curl config files. Existing messaging E2Es usually skip live Slack auth validation for fake tokens; add hermetic coverage that validates config trust propagation and cleanup through the actual onboarding hook without requiring live Slack secrets.
    • Suggested test: Add slack-credential-validation-hermetic-e2e using the fake Slack API and real validateSlackCredentials onboarding hook path.

Dispatch hint

  • Workflow: .github/workflows/nightly-e2e.yaml
  • jobs input: cloud-onboard-e2e,credential-migration-e2e,credential-sanitization-e2e,inference-routing-e2e,messaging-providers-e2e,sandbox-operations-e2e

@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

E2E Scenario Advisor Recommendation

Required scenario E2E: ubuntu-repo-cloud-openclaw, ubuntu-repo-cloud-openclaw-slack, gpu-repo-local-ollama-openclaw
Optional scenario E2E: ubuntu-repo-cloud-hermes, wsl-repo-cloud-openclaw, macos-repo-cloud-openclaw

Dispatch required scenario E2E:

  • gh workflow run e2e-scenarios.yaml --ref <pr-head-ref> --field scenarios=ubuntu-repo-cloud-openclaw
  • gh workflow run e2e-scenarios.yaml --ref <pr-head-ref> --field scenarios=ubuntu-repo-cloud-openclaw-slack
  • gh workflow run e2e-scenarios.yaml --ref <pr-head-ref> --field scenarios=gpu-repo-local-ollama-openclaw

Workflow run

Full scenario advisor summary

E2E Scenario Advisor

Base: origin/main
Head: HEAD
Confidence: high

Required scenario E2E

  • ubuntu-repo-cloud-openclaw: Primary Ubuntu repo scenario exercises the changed runner/onboarding state, gateway-state reconciliation, cloud inference curl probes, credential staging/no-plaintext checks, and baseline sandbox state assertions.
    • Dispatch: gh workflow run e2e-scenarios.yaml --ref <pr-head-ref> --field scenarios=ubuntu-repo-cloud-openclaw
  • ubuntu-repo-cloud-openclaw-slack: Slack credential validation and credential-safe curl config handling changed; this is the routed scenario that exercises Slack onboarding/provider state and messaging secret-leak checks.
    • Dispatch: gh workflow run e2e-scenarios.yaml --ref <pr-head-ref> --field scenarios=ubuntu-repo-cloud-openclaw-slack
  • gpu-repo-local-ollama-openclaw: Ollama proxy/pull and local provider health paths changed; the GPU local-Ollama scenario is the only routed scenario covering local Ollama inference and the Ollama auth-proxy suite.
    • Dispatch: gh workflow run e2e-scenarios.yaml --ref <pr-head-ref> --field scenarios=gpu-repo-local-ollama-openclaw

Optional scenario E2E

  • ubuntu-repo-cloud-hermes: Adjacent cloud inference/onboarding coverage with the Hermes agent; useful if runner/onboarding or curl-probe changes might be agent-sensitive, but OpenClaw is the primary target.
    • Dispatch: gh workflow run e2e-scenarios.yaml --ref <pr-head-ref> --field scenarios=ubuntu-repo-cloud-hermes
  • wsl-repo-cloud-openclaw: Optional platform coverage for changed filesystem, credential, curl, and onboarding/session behavior on WSL. Special-runner scenario, so not required unless WSL-specific risk is suspected.
    • Dispatch: gh workflow run e2e-scenarios.yaml --ref <pr-head-ref> --field scenarios=wsl-repo-cloud-openclaw
  • macos-repo-cloud-openclaw: Optional platform smoke for filesystem/security and CLI state behavior on macOS. Special-runner scenario and Docker-dependent suites are skipped, so keep as adjacent coverage.
    • Dispatch: gh workflow run e2e-scenarios.yaml --ref <pr-head-ref> --field scenarios=macos-repo-cloud-openclaw

Relevant changed files

  • nemoclaw/src/blueprint/state.ts
  • src/lib/actions/sandbox/gateway-state.ts
  • src/lib/adapters/http/probe.ts
  • src/lib/credentials/store.ts
  • src/lib/inference/health.ts
  • src/lib/inference/ollama/proxy.ts
  • src/lib/messaging/channels/slack/hooks/credential-validation.ts
  • src/lib/runner.ts
  • src/lib/security/credential-filter.ts
  • src/lib/state/onboard-session.ts

@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

PR Review Advisor

Findings: 1 needs attention, 14 worth checking, 0 nice ideas
Since last review: 1 prior item resolved, 8 still apply, 1 new item found

Review findings

🛠️ Needs attention

  • Security-sensitive curl policy remains embedded in a growing high-churn module (src/lib/adapters/http/probe.ts:131): The SSRF, local-file-read, trusted-config, and multi-transfer curl policy still lives inline in the already-large HTTP probe module. This PR substantially grows the file while keeping raw curl argv parsing, validation, and spawn argument construction in the same high-churn surface, increasing the chance that future probe changes bypass this boundary.
    • Recommendation: Extract curl probe validation and spawn-argument construction into a small dedicated helper with typed inputs, or centralize callers on a typed request builder/trusted-config factory. Keep raw curl argv handling behind that helper.
    • Evidence: CURL_* allowlists/denylists, validateCurlProbeArgs(), isTrustedCurlConfigPath(), and buildCurlProbeSpawnArgs() are all in src/lib/adapters/http/probe.ts; deterministic monolith data reports probe.ts grew by 183 lines to 706 lines.

🔎 Worth checking

  • Source-of-truth review needed: src/lib/adapters/http/probe.ts curl argument validation: The advisor marked localized patch analysis as needs_followup.
    • Recommendation: Identify the invalid state, source boundary, source-fix constraint, regression test, and removal condition before merging the localized behavior.
    • Evidence: validateCurlProbeArgs() and buildCurlProbeSpawnArgs() remain inline in probe.ts.
  • Source-of-truth review needed: src/lib/actions/sandbox/gateway-state.ts identity-drift known_hosts cleanup: The advisor marked localized patch analysis as needs_followup.
    • Recommendation: Identify the invalid state, source boundary, source-fix constraint, regression test, and removal condition before merging the localized behavior.
    • Evidence: The identity_drift branch catches read/write errors around known_hosts cleanup and retries reconciliation.
  • Source-of-truth review needed: src/lib/actions/uninstall/plan.ts shim metadata fallback: The advisor marked localized patch analysis as needs_followup.
    • Recommendation: Identify the invalid state, source boundary, source-fix constraint, regression test, and removal condition before merging the localized behavior.
    • Evidence: classifyShimPath() opens with O_NOFOLLOW and falls back to classifyShimPathByMetadata() for ELOOP, EISDIR, EACCES, EPERM, ENXIO, ENODEV, and ENOTSUP.
  • Source-of-truth review needed: src/lib/actions/uninstall/run-plan.ts direct PATH command discovery: The advisor marked localized patch analysis as needs_followup.
    • Recommendation: Identify the invalid state, source boundary, source-fix constraint, regression test, and removal condition before merging the localized behavior.
    • Evidence: defaultCommandExists() rejects empty/NUL commands and iterates PATH candidates with fs.accessSync().
  • Source-of-truth review needed: src/lib/credentials/store.ts secureUnlink legacy credential cleanup: The advisor marked localized patch analysis as needs_followup.
    • Recommendation: Identify the invalid state, source boundary, source-fix constraint, regression test, and removal condition before merging the localized behavior.
    • Evidence: secureUnlink() uses O_NOFOLLOW where available, zeroes regular files by descriptor, and later unlinks opened files or final symlinks best-effort.
  • Source-of-truth review needed: src/lib/security/credential-filter.ts no-follow read and atomic sanitize write: The advisor marked localized patch analysis as needs_followup.
    • Recommendation: Identify the invalid state, source boundary, source-fix constraint, regression test, and removal condition before merging the localized behavior.
    • Evidence: readRegularFileNoFollow() returns null for unsafe shapes; writeFileAtomically() writes a 0600 temp file and renames it.
  • Source-of-truth review needed: src/lib/inference/ollama/proxy.ts local HTTP pull restrictions: The advisor marked localized patch analysis as needs_followup.
    • Recommendation: Identify the invalid state, source boundary, source-fix constraint, regression test, and removal condition before merging the localized behavior.
    • Evidence: buildLocalOllamaPullUrl() allowlists local hosts; normalizeOllamaPullModel() rejects empty/NUL/newline ids.
  • Trusted curl config validation still relies on caller-provided path equality (src/lib/adapters/http/probe.ts:220): The central curl validator accepts --config/-K when the normalized path equals an entry in opts.trustedConfigFiles, but it does not verify that the file is regular, non-symlinked, owner-appropriate, mode 0600, or under a NemoClaw-created temp directory. Current changed callers create restrictive temp files, but the policy boundary itself remains dependent on caller discipline.
    • Recommendation: Centralize trusted curl config creation/registration in the probe helper, or have the validator open/stat trusted configs with no-follow semantics and enforce regular-file shape, restrictive permissions, and expected temp-directory ownership before allowing --config/-K.
    • Evidence: isTrustedCurlConfigPath() resolves paths and checks includes(candidate); Slack and Kimi pass trustedConfigFiles created by their callers, while probe.test.ts only covers the trusted-config happy path.
  • Ollama HTTP pull SSRF/model-id guards lack direct negative tests (src/lib/inference/ollama/proxy.ts:474): The HTTP pull path now allowlists local Ollama hosts and rejects empty/NUL/newline model ids, but no direct test evidence was found that these checks fail before curl is spawned. Because this is a local-network SSRF-sensitive boundary, the negative contract should be pinned.
    • Recommendation: Add tests that force an unexpected resolved Ollama host and verify no curl process is spawned, plus tests for empty, NUL, CR, and LF model ids. Also verify the caller receives a controlled failure rather than an unhandled rejection.
    • Evidence: buildLocalOllamaPullUrl() allowlists 127.0.0.1, localhost, ::1, and host.docker.internal; normalizeOllamaPullModel() rejects empty/NUL/newline ids; grep found no matching proxy test for these negative paths.
  • Direct PATH command discovery needs a metacharacter regression test (src/lib/actions/uninstall/run-plan.ts:67): Replacing shell-based command -v with direct PATH probing is the right fix, but the actual defaultCommandExists implementation is private and not directly covered for shell metacharacter inputs. The current tests mostly inject commandExists seams, so they do not prove the default path never invokes sh -c.
    • Recommendation: Add a caller-seam or exported-helper test showing a command such as docker;rm -rf / is searched as a literal PATH candidate and no shell command is invoked.
    • Evidence: defaultCommandExists() rejects empty/NUL commands and uses fs.accessSync over PATH candidates; run-plan.test.ts uses injected commandExists callbacks and has no metacharacter/no-shell assertion.
  • Legacy credential symlink cleanup contract is not directly proven (src/lib/credentials/store.ts:270): secureUnlink() is meant to avoid truncating or writing through a planted final-component credentials.json symlink, but the changed tests do not directly prove removeLegacyCredentialsFile() preserves a symlink target while removing or safely leaving the link.
    • Recommendation: Add a credential-store test that creates a target file and final-component credentials.json symlink, calls removeLegacyCredentialsFile(), and asserts the target content is unchanged while the link is removed or safely left untouched according to platform support.
    • Evidence: secureUnlink() uses O_NOFOLLOW where available and best-effort symlink handling; no changed credential-store test for final-component legacy credential symlink cleanup was found.
  • Credential-filter atomic write failure details need direct coverage (src/lib/security/credential-filter.ts:50): sanitizeConfigFile() now reads without following final-component symlinks and writes through a 0600 temp file plus rename. The added symlink test covers not modifying a symlink target, but it does not assert temp-file mode, rename target, or that the original config remains intact if rename fails.
    • Recommendation: Add tests that record writeFileSync options and renameSync paths for sanitizeConfigFile(), verify the temp file is 0600 and renamed to the original path, and verify the original config remains intact on renameSync failure.
    • Evidence: writeFileAtomically() writes .${basename}.${pid}.${randomUUID()}.tmp with { mode: 0o600 } and renames it to the final path; credential-filter.test.ts covers symlink non-follow only.
  • Best-effort recovery paths need targeted regression tests (src/lib/actions/sandbox/gateway-state.ts:459): Several changed recovery paths intentionally tolerate invalid host filesystem state or cleanup failures. These paths are security-sensitive because they decide whether NemoClaw retries safely, follows symlinks, reads untrusted files, or reports cleanup success.
    • Recommendation: Add focused behavior tests for identity-drift retry when known_hosts is missing or unreadable, shim ELOOP fallback without reading symlink targets, descriptor-pinned credential reads during file swaps, and no-follow/atomic paths preserving original files on failures.
    • Evidence: The identity_drift branch catches known_hosts read/write errors and retries; classifyShimPath() falls back to metadata for ELOOP/EACCES/EPERM/device errors; secureUnlink() and readRegularFileNoFollow() use best-effort/no-follow behavior.
  • Shim metadata fallback needs an explicit source-of-truth contract (src/lib/actions/uninstall/plan.ts:80): classifyShimPath() opens shims with O_NOFOLLOW, then falls back to lstat-only metadata for ELOOP, directories, permission errors, and device-like errors. That avoids reading symlink targets, but the broad fallback contract is not documented or fully tested, and regular unreadable installer-managed wrappers will be preserved as foreign because contents are unavailable.
    • Recommendation: Document the invalid host states this fallback handles, why unreadable regular files must be preserved rather than removed, and add tests proving ELOOP fallback does not call readFileSync on the symlink target.
    • Evidence: classifyShimPath() returns classifyShimPathByMetadata() for ELOOP, EISDIR, EACCES, EPERM, ENXIO, ENODEV, and ENOTSUP; the visible test only asserts an ELOOP symlink classification through the run-plan path.

🌱 Nice ideas

  • None.
Consider writing more tests for
  • **Runtime validation** — validateCurlProbeArgs rejects trusted --config when the trusted path is a symlink, world-readable file, or non-regular file before spawning curl. The changed surfaces include process spawning, curl/network probes, Ollama local HTTP pulls, uninstall host cleanup, credential cleanup, atomic state/config writes, and sandbox gateway recovery. Unit coverage improved, but security-negative matrices and filesystem recovery behavior still need targeted validation.
  • **Runtime validation** — pullOllamaModelViaHttp rejects unexpected resolved Ollama host before spawning curl and returns a controlled failure. The changed surfaces include process spawning, curl/network probes, Ollama local HTTP pulls, uninstall host cleanup, credential cleanup, atomic state/config writes, and sandbox gateway recovery. Unit coverage improved, but security-negative matrices and filesystem recovery behavior still need targeted validation.
  • **Runtime validation** — pullOllamaModelViaHttp rejects empty, NUL, CR, and LF model ids before spawning curl and returns a controlled failure. The changed surfaces include process spawning, curl/network probes, Ollama local HTTP pulls, uninstall host cleanup, credential cleanup, atomic state/config writes, and sandbox gateway recovery. Unit coverage improved, but security-negative matrices and filesystem recovery behavior still need targeted validation.
  • **Runtime validation** — defaultCommandExists treats docker;rm -rf / as a literal PATH filename and never invokes sh -c. The changed surfaces include process spawning, curl/network probes, Ollama local HTTP pulls, uninstall host cleanup, credential cleanup, atomic state/config writes, and sandbox gateway recovery. Unit coverage improved, but security-negative matrices and filesystem recovery behavior still need targeted validation.
  • **Runtime validation** — removeLegacyCredentialsFile unlinks or safely leaves a final-component credentials.json symlink without modifying its target. The changed surfaces include process spawning, curl/network probes, Ollama local HTTP pulls, uninstall host cleanup, credential cleanup, atomic state/config writes, and sandbox gateway recovery. Unit coverage improved, but security-negative matrices and filesystem recovery behavior still need targeted validation.
  • **Ollama HTTP pull SSRF/model-id guards lack direct negative tests** — Add tests that force an unexpected resolved Ollama host and verify no curl process is spawned, plus tests for empty, NUL, CR, and LF model ids. Also verify the caller receives a controlled failure rather than an unhandled rejection.
  • **Direct PATH command discovery needs a metacharacter regression test** — Add a caller-seam or exported-helper test showing a command such as docker;rm -rf / is searched as a literal PATH candidate and no shell command is invoked.
  • **Legacy credential symlink cleanup contract is not directly proven** — Add a credential-store test that creates a target file and final-component credentials.json symlink, calls removeLegacyCredentialsFile(), and asserts the target content is unchanged while the link is removed or safely left untouched according to platform support.
Since last review details

Current findings:

  • Source-of-truth review needed: src/lib/adapters/http/probe.ts curl argument validation: The advisor marked localized patch analysis as needs_followup.
    • Recommendation: Identify the invalid state, source boundary, source-fix constraint, regression test, and removal condition before merging the localized behavior.
    • Evidence: validateCurlProbeArgs() and buildCurlProbeSpawnArgs() remain inline in probe.ts.
  • Source-of-truth review needed: src/lib/actions/sandbox/gateway-state.ts identity-drift known_hosts cleanup: The advisor marked localized patch analysis as needs_followup.
    • Recommendation: Identify the invalid state, source boundary, source-fix constraint, regression test, and removal condition before merging the localized behavior.
    • Evidence: The identity_drift branch catches read/write errors around known_hosts cleanup and retries reconciliation.
  • Source-of-truth review needed: src/lib/actions/uninstall/plan.ts shim metadata fallback: The advisor marked localized patch analysis as needs_followup.
    • Recommendation: Identify the invalid state, source boundary, source-fix constraint, regression test, and removal condition before merging the localized behavior.
    • Evidence: classifyShimPath() opens with O_NOFOLLOW and falls back to classifyShimPathByMetadata() for ELOOP, EISDIR, EACCES, EPERM, ENXIO, ENODEV, and ENOTSUP.
  • Source-of-truth review needed: src/lib/actions/uninstall/run-plan.ts direct PATH command discovery: The advisor marked localized patch analysis as needs_followup.
    • Recommendation: Identify the invalid state, source boundary, source-fix constraint, regression test, and removal condition before merging the localized behavior.
    • Evidence: defaultCommandExists() rejects empty/NUL commands and iterates PATH candidates with fs.accessSync().
  • Source-of-truth review needed: src/lib/credentials/store.ts secureUnlink legacy credential cleanup: The advisor marked localized patch analysis as needs_followup.
    • Recommendation: Identify the invalid state, source boundary, source-fix constraint, regression test, and removal condition before merging the localized behavior.
    • Evidence: secureUnlink() uses O_NOFOLLOW where available, zeroes regular files by descriptor, and later unlinks opened files or final symlinks best-effort.
  • Source-of-truth review needed: src/lib/security/credential-filter.ts no-follow read and atomic sanitize write: The advisor marked localized patch analysis as needs_followup.
    • Recommendation: Identify the invalid state, source boundary, source-fix constraint, regression test, and removal condition before merging the localized behavior.
    • Evidence: readRegularFileNoFollow() returns null for unsafe shapes; writeFileAtomically() writes a 0600 temp file and renames it.
  • Source-of-truth review needed: src/lib/inference/ollama/proxy.ts local HTTP pull restrictions: The advisor marked localized patch analysis as needs_followup.
    • Recommendation: Identify the invalid state, source boundary, source-fix constraint, regression test, and removal condition before merging the localized behavior.
    • Evidence: buildLocalOllamaPullUrl() allowlists local hosts; normalizeOllamaPullModel() rejects empty/NUL/newline ids.
  • Security-sensitive curl policy remains embedded in a growing high-churn module (src/lib/adapters/http/probe.ts:131): The SSRF, local-file-read, trusted-config, and multi-transfer curl policy still lives inline in the already-large HTTP probe module. This PR substantially grows the file while keeping raw curl argv parsing, validation, and spawn argument construction in the same high-churn surface, increasing the chance that future probe changes bypass this boundary.
    • Recommendation: Extract curl probe validation and spawn-argument construction into a small dedicated helper with typed inputs, or centralize callers on a typed request builder/trusted-config factory. Keep raw curl argv handling behind that helper.
    • Evidence: CURL_* allowlists/denylists, validateCurlProbeArgs(), isTrustedCurlConfigPath(), and buildCurlProbeSpawnArgs() are all in src/lib/adapters/http/probe.ts; deterministic monolith data reports probe.ts grew by 183 lines to 706 lines.
  • Trusted curl config validation still relies on caller-provided path equality (src/lib/adapters/http/probe.ts:220): The central curl validator accepts --config/-K when the normalized path equals an entry in opts.trustedConfigFiles, but it does not verify that the file is regular, non-symlinked, owner-appropriate, mode 0600, or under a NemoClaw-created temp directory. Current changed callers create restrictive temp files, but the policy boundary itself remains dependent on caller discipline.
    • Recommendation: Centralize trusted curl config creation/registration in the probe helper, or have the validator open/stat trusted configs with no-follow semantics and enforce regular-file shape, restrictive permissions, and expected temp-directory ownership before allowing --config/-K.
    • Evidence: isTrustedCurlConfigPath() resolves paths and checks includes(candidate); Slack and Kimi pass trustedConfigFiles created by their callers, while probe.test.ts only covers the trusted-config happy path.
  • Ollama HTTP pull SSRF/model-id guards lack direct negative tests (src/lib/inference/ollama/proxy.ts:474): The HTTP pull path now allowlists local Ollama hosts and rejects empty/NUL/newline model ids, but no direct test evidence was found that these checks fail before curl is spawned. Because this is a local-network SSRF-sensitive boundary, the negative contract should be pinned.
    • Recommendation: Add tests that force an unexpected resolved Ollama host and verify no curl process is spawned, plus tests for empty, NUL, CR, and LF model ids. Also verify the caller receives a controlled failure rather than an unhandled rejection.
    • Evidence: buildLocalOllamaPullUrl() allowlists 127.0.0.1, localhost, ::1, and host.docker.internal; normalizeOllamaPullModel() rejects empty/NUL/newline ids; grep found no matching proxy test for these negative paths.
  • Direct PATH command discovery needs a metacharacter regression test (src/lib/actions/uninstall/run-plan.ts:67): Replacing shell-based command -v with direct PATH probing is the right fix, but the actual defaultCommandExists implementation is private and not directly covered for shell metacharacter inputs. The current tests mostly inject commandExists seams, so they do not prove the default path never invokes sh -c.
    • Recommendation: Add a caller-seam or exported-helper test showing a command such as docker;rm -rf / is searched as a literal PATH candidate and no shell command is invoked.
    • Evidence: defaultCommandExists() rejects empty/NUL commands and uses fs.accessSync over PATH candidates; run-plan.test.ts uses injected commandExists callbacks and has no metacharacter/no-shell assertion.
  • Legacy credential symlink cleanup contract is not directly proven (src/lib/credentials/store.ts:270): secureUnlink() is meant to avoid truncating or writing through a planted final-component credentials.json symlink, but the changed tests do not directly prove removeLegacyCredentialsFile() preserves a symlink target while removing or safely leaving the link.
    • Recommendation: Add a credential-store test that creates a target file and final-component credentials.json symlink, calls removeLegacyCredentialsFile(), and asserts the target content is unchanged while the link is removed or safely left untouched according to platform support.
    • Evidence: secureUnlink() uses O_NOFOLLOW where available and best-effort symlink handling; no changed credential-store test for final-component legacy credential symlink cleanup was found.
  • Credential-filter atomic write failure details need direct coverage (src/lib/security/credential-filter.ts:50): sanitizeConfigFile() now reads without following final-component symlinks and writes through a 0600 temp file plus rename. The added symlink test covers not modifying a symlink target, but it does not assert temp-file mode, rename target, or that the original config remains intact if rename fails.
    • Recommendation: Add tests that record writeFileSync options and renameSync paths for sanitizeConfigFile(), verify the temp file is 0600 and renamed to the original path, and verify the original config remains intact on renameSync failure.
    • Evidence: writeFileAtomically() writes .${basename}.${pid}.${randomUUID()}.tmp with { mode: 0o600 } and renames it to the final path; credential-filter.test.ts covers symlink non-follow only.
  • Best-effort recovery paths need targeted regression tests (src/lib/actions/sandbox/gateway-state.ts:459): Several changed recovery paths intentionally tolerate invalid host filesystem state or cleanup failures. These paths are security-sensitive because they decide whether NemoClaw retries safely, follows symlinks, reads untrusted files, or reports cleanup success.
    • Recommendation: Add focused behavior tests for identity-drift retry when known_hosts is missing or unreadable, shim ELOOP fallback without reading symlink targets, descriptor-pinned credential reads during file swaps, and no-follow/atomic paths preserving original files on failures.
    • Evidence: The identity_drift branch catches known_hosts read/write errors and retries; classifyShimPath() falls back to metadata for ELOOP/EACCES/EPERM/device errors; secureUnlink() and readRegularFileNoFollow() use best-effort/no-follow behavior.
  • Shim metadata fallback needs an explicit source-of-truth contract (src/lib/actions/uninstall/plan.ts:80): classifyShimPath() opens shims with O_NOFOLLOW, then falls back to lstat-only metadata for ELOOP, directories, permission errors, and device-like errors. That avoids reading symlink targets, but the broad fallback contract is not documented or fully tested, and regular unreadable installer-managed wrappers will be preserved as foreign because contents are unavailable.
    • Recommendation: Document the invalid host states this fallback handles, why unreadable regular files must be preserved rather than removed, and add tests proving ELOOP fallback does not call readFileSync on the symlink target.
    • Evidence: classifyShimPath() returns classifyShimPathByMetadata() for ELOOP, EISDIR, EACCES, EPERM, ENXIO, ENODEV, and ENOTSUP; the visible test only asserts an ELOOP symlink classification through the run-plan path.

Workflow run details

This is an automated advisory review. A human maintainer must make the final merge decision.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 4

🧹 Nitpick comments (2)
src/lib/security/credential-filter.ts (1)

45-50: 💤 Low value

Redundant chmodSync after atomic rename creates a minor TOCTOU window.

The writeFileSync on line 47 already applies mode: 0o600. The subsequent chmodSync after renameSync is redundant and introduces a brief window where the file exists at the final path before the chmod completes. While the risk is minimal (initial mode is already correct), removing the extra chmod simplifies the code.

♻️ Remove redundant chmod
 function writeFileAtomically(filePath: string, contents: string): void {
   const tmpPath = join(dirname(filePath), `.${basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
   writeFileSync(tmpPath, contents, { mode: 0o600 });
   renameSync(tmpPath, filePath);
-  chmodSync(filePath, 0o600);
 }
🤖 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 `@src/lib/security/credential-filter.ts` around lines 45 - 50, The
writeFileAtomically function has a redundant chmodSync call after renameSync
that creates a small TOCTOU window; remove the final chmodSync(filePath, 0o600)
line and rely on writeFileSync(tmpPath, contents, { mode: 0o600 }) plus the
atomic renameSync(tmpPath, filePath) to ensure the destination file has correct
permissions, leaving tmpPath creation, writeFileSync and renameSync unchanged.
nemoclaw/src/blueprint/state.test.ts (1)

179-183: 💤 Low value

Test description is now misleading after clearState behavior change.

The test is named "does nothing when no file exists", but clearState now unconditionally writes a blank state file via the atomic write path. The test still passes (no throw), but the description no longer reflects the actual behavior.

Consider updating the test name to "creates blank state when no file exists" and optionally asserting that the state file is created.

🤖 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 `@nemoclaw/src/blueprint/state.test.ts` around lines 179 - 183, Rename and
update the test that currently reads "does nothing when no file exists" to
reflect the new behavior of clearState: change the description to "creates blank
state when no file exists" and add an assertion that the state file is created
(or that the atomic write path was invoked) after calling clearState; target the
test in state.test.ts that calls clearState() so the test both documents and
verifies the new unconditional blank-state write behavior.
🤖 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 `@src/lib/actions/uninstall/plan.ts`:
- Line 34: The openSync call uses fs.constants.O_NOFOLLOW which is undefined on
Windows so symlink protection is lost; change the flag computation in the code
around openSync(shimPath, ...) to only OR in fs.constants.O_NOFOLLOW when it is
defined (e.g. compute flags = fs.constants.O_RDONLY and if (typeof
fs.constants.O_NOFOLLOW !== 'undefined') flags |= fs.constants.O_NOFOLLOW) and
then call openSync(shimPath, flags); apply the same conditional-flag pattern to
the analogous code in credential-filter.ts to ensure consistent, platform-safe
behavior.

In `@src/lib/credentials/store.ts`:
- Line 240: The open call using fs.openSync(filePath, fs.constants.O_RDWR |
fs.constants.O_NOFOLLOW) can silently drop O_NOFOLLOW on Windows and cause
secureUnlink to zero-out a symlink target; update secureUnlink to detect this:
if fs.constants.O_NOFOLLOW is undefined or process.platform === 'win32', use
fs.lstatSync(filePath) first and abort if lstat.isSymbolicLink() (or, on
Windows, skip the secure zeroing and perform a plain unlink instead), otherwise
keep the existing open with O_NOFOLLOW; ensure you reference the existing
fs.openSync call and the secureUnlink function when making the change.

In `@src/lib/security/credential-filter.test.ts`:
- Around line 134-143: The test creates a symlink with symlinkSync to verify
sanitizeConfigFile doesn't follow symlinks, but symlink creation can fail on
Windows; wrap the symlinkSync(...) call in a try/catch and, if it throws (e.g.,
EPERM/EACCES), call it.skip with a clear message so the test is skipped instead
of failing. Ensure you only skip when symlink creation fails (do not swallow
other errors), and keep targetPath, linkPath and tmpDir usage unchanged so the
rest of the test still asserts that the target file content is untouched by
sanitizeConfigFile.

In `@src/lib/security/credential-filter.ts`:
- Around line 30-43: The readRegularFileNoFollow function currently falls back
to bitwise OR with (constants.O_NOFOLLOW ?? 0) which silently follows symlinks
on platforms where O_NOFOLLOW is undefined; instead, detect the absence of
constants.O_NOFOLLOW and fail explicitly or implement a platform-specific guard:
inside readRegularFileNoFollow check if constants.O_NOFOLLOW is undefined and
either throw/log an error indicating symlink protection is unavailable, or
perform an lstatSync(filePath) prior to openSync to ensure the path is not a
symlink (use fs.lstatSync(...).isSymbolicLink()) before proceeding with
openSync/readFileSync; reference the constants.O_NOFOLLOW symbol and the
readRegularFileNoFollow function when making the change.

---

Nitpick comments:
In `@nemoclaw/src/blueprint/state.test.ts`:
- Around line 179-183: Rename and update the test that currently reads "does
nothing when no file exists" to reflect the new behavior of clearState: change
the description to "creates blank state when no file exists" and add an
assertion that the state file is created (or that the atomic write path was
invoked) after calling clearState; target the test in state.test.ts that calls
clearState() so the test both documents and verifies the new unconditional
blank-state write behavior.

In `@src/lib/security/credential-filter.ts`:
- Around line 45-50: The writeFileAtomically function has a redundant chmodSync
call after renameSync that creates a small TOCTOU window; remove the final
chmodSync(filePath, 0o600) line and rely on writeFileSync(tmpPath, contents, {
mode: 0o600 }) plus the atomic renameSync(tmpPath, filePath) to ensure the
destination file has correct permissions, leaving tmpPath creation,
writeFileSync and renameSync unchanged.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 195b6fa8-2539-4baa-a494-8e5a0c02565b

📥 Commits

Reviewing files that changed from the base of the PR and between 2b68caa and e66702e.

📒 Files selected for processing (15)
  • nemoclaw/src/blueprint/state.test.ts
  • nemoclaw/src/blueprint/state.ts
  • src/lib/actions/sandbox/gateway-state.ts
  • src/lib/actions/uninstall/plan.ts
  • src/lib/actions/uninstall/run-plan.ts
  • src/lib/adapters/http/probe.test.ts
  • src/lib/adapters/http/probe.ts
  • src/lib/credentials/store.ts
  • src/lib/inference/ollama/proxy.ts
  • src/lib/runner-argv.test.ts
  • src/lib/runner.ts
  • src/lib/security/credential-filter.test.ts
  • src/lib/security/credential-filter.ts
  • src/lib/state/onboard-session.test.ts
  • src/lib/state/onboard-session.ts

Comment thread src/lib/actions/uninstall/plan.ts Outdated
Comment thread src/lib/credentials/store.ts Outdated
Comment thread src/lib/security/credential-filter.test.ts
Comment thread src/lib/security/credential-filter.ts
@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Selective E2E Results — ✅ All requested jobs passed

Run: 27046738496
Target ref: e66702e52fbc19a5442b0969d598113d9a9bd907
Workflow ref: main
Requested jobs: credential-migration-e2e,credential-sanitization-e2e,inference-routing-e2e,gpu-e2e,sandbox-operations-e2e,onboard-resume-e2e,state-backup-restore-e2e
Summary: 6 passed, 0 failed, 1 skipped

Job Result
credential-migration-e2e ✅ success
credential-sanitization-e2e ✅ success
gpu-e2e ⏭️ skipped
inference-routing-e2e ✅ success
onboard-resume-e2e ✅ success
sandbox-operations-e2e ✅ success
state-backup-restore-e2e ✅ success

@coderabbitai coderabbitai Bot 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.

♻️ Duplicate comments (1)
src/lib/actions/uninstall/plan.ts (1)

62-65: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Gate the descriptor-read path on O_NOFOLLOW support.

Node documents POSIX open flags as platform-dependent and recommends checking for their presence; O_NOFOLLOW is not available on Windows. If this CLI still runs there, this openSync() call degrades to a normal open, so the classifier reads through the link instead of taking the lstatSync() fallback. The downstream effect here is misclassification: a shim symlink can look like a regular file and be preserved instead of removed. (nodejs.org)

Proposed fix
 export function classifyShimPath(shimPath: string, deps: FileSystemDeps = {}): ShimClassification {
   const lstatSync = deps.lstatSync ?? fs.lstatSync;
   const openSync = deps.openSync ?? fs.openSync;
   const fstatSync = deps.fstatSync ?? fs.fstatSync;
   const readFileSync = deps.readFileSync ?? fs.readFileSync;
   const closeSync = deps.closeSync ?? fs.closeSync;
+
+  if (typeof fs.constants.O_NOFOLLOW !== "number") {
+    return classifyShimPathByMetadata(shimPath, lstatSync);
+  }
+
   try {
     const fd = openSync(
       shimPath,
-      fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK,
+      fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_NONBLOCK ?? 0),
     );
🤖 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 `@src/lib/actions/uninstall/plan.ts` around lines 62 - 65, The openSync call
using fs.constants.O_NOFOLLOW is unsafe on platforms that don't support
O_NOFOLLOW (e.g., Windows) so the code path that reads via descriptor can
silently follow symlinks; instead detect support for O_NOFOLLOW before using
that path: check for the presence of fs.constants.O_NOFOLLOW (or similar) and
only build the flags and call openSync(shimPath, flags) when it's defined; if
not defined, skip the descriptor-read branch that uses openSync and fall back to
the existing lstatSync-based classifier logic (reference shimPath, openSync, fd,
O_NOFOLLOW, and lstatSync in your changes).
🧹 Nitpick comments (1)
src/lib/actions/uninstall/run-plan.test.ts (1)

39-60: ⚡ Quick win

Assert that the ELOOP fallback is the branch under test.

Right now this still passes if buildRunPlan() regresses to metadata-only shim classification, because the end result is the same. Make openSync a vi.fn() and assert it was called so this test locks in the new descriptor-based fallback path.

Proposed tweak
   it("builds a plan using host paths and shim classification", () => {
+    const openSync = vi.fn(() => {
+      const error = new Error("symlink") as NodeJS.ErrnoException;
+      error.code = "ELOOP";
+      throw error;
+    });
+
     const { paths, plan } = buildRunPlan(
       { assumeYes: true, deleteModels: false, keepOpenShell: false },
       {
         env: { HOME: "/home/test", TMPDIR: "/tmp/test" } as NodeJS.ProcessEnv,
         fs: {
           lstatSync: (() => ({ isFile: () => false, isSymbolicLink: () => true })) as never,
-          openSync: (() => {
-            const error = new Error("symlink") as NodeJS.ErrnoException;
-            error.code = "ELOOP";
-            throw error;
-          }) as never,
+          openSync: openSync as never,
         },
       },
     );
 
+    expect(openSync).toHaveBeenCalled();
     expect(paths.nemoclawShimPath).toBe("/home/test/.local/bin/nemoclaw");
🤖 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 `@src/lib/actions/uninstall/run-plan.test.ts` around lines 39 - 60, The test
for buildRunPlan should ensure the ELOOP fallback path is exercised by making
the fs.openSync used in buildRunPlan a spy and asserting it was called; replace
the inline throwing openSync stub with a vi.fn() that throws the ELOOP
ErrnoException and then add an expectation that that spy was invoked (e.g.,
expect(openSyncSpy).toHaveBeenCalled()), while keeping the lstatSync stub that
reports a symlink and the existing assertions for nemoclawShimPath and the
delete-shim action so the test locks in the descriptor-based fallback branch.
🤖 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.

Duplicate comments:
In `@src/lib/actions/uninstall/plan.ts`:
- Around line 62-65: The openSync call using fs.constants.O_NOFOLLOW is unsafe
on platforms that don't support O_NOFOLLOW (e.g., Windows) so the code path that
reads via descriptor can silently follow symlinks; instead detect support for
O_NOFOLLOW before using that path: check for the presence of
fs.constants.O_NOFOLLOW (or similar) and only build the flags and call
openSync(shimPath, flags) when it's defined; if not defined, skip the
descriptor-read branch that uses openSync and fall back to the existing
lstatSync-based classifier logic (reference shimPath, openSync, fd, O_NOFOLLOW,
and lstatSync in your changes).

---

Nitpick comments:
In `@src/lib/actions/uninstall/run-plan.test.ts`:
- Around line 39-60: The test for buildRunPlan should ensure the ELOOP fallback
path is exercised by making the fs.openSync used in buildRunPlan a spy and
asserting it was called; replace the inline throwing openSync stub with a
vi.fn() that throws the ELOOP ErrnoException and then add an expectation that
that spy was invoked (e.g., expect(openSyncSpy).toHaveBeenCalled()), while
keeping the lstatSync stub that reports a symlink and the existing assertions
for nemoclawShimPath and the delete-shim action so the test locks in the
descriptor-based fallback branch.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: ffd670b1-3ab4-42ba-b2e5-ca009dc9557b

📥 Commits

Reviewing files that changed from the base of the PR and between e66702e and 08b9173.

📒 Files selected for processing (4)
  • src/lib/actions/uninstall/plan.ts
  • src/lib/actions/uninstall/run-plan.test.ts
  • src/lib/actions/uninstall/run-plan.ts
  • src/lib/adapters/http/probe.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/lib/actions/uninstall/run-plan.ts
  • src/lib/adapters/http/probe.ts

@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Selective E2E Results — ✅ All requested jobs passed

Run: 27047329923
Target ref: 08b91734f8b57f9606d9e4e446d5f8ab725dd345
Workflow ref: main
Requested jobs: credential-migration-e2e,credential-sanitization-e2e,inference-routing-e2e,sandbox-operations-e2e,onboard-resume-e2e,cloud-onboard-e2e,state-backup-restore-e2e
Summary: 7 passed, 0 failed, 0 skipped

Job Result
cloud-onboard-e2e ✅ success
credential-migration-e2e ✅ success
credential-sanitization-e2e ✅ success
inference-routing-e2e ✅ success
onboard-resume-e2e ✅ success
sandbox-operations-e2e ✅ success
state-backup-restore-e2e ✅ success

@coderabbitai coderabbitai Bot 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.

Caution

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

⚠️ Outside diff range comments (1)
src/lib/adapters/http/probe.ts (1)

243-253: ⚠️ Potential issue | 🟠 Major

Prepend -q in buildCurlProbeSpawnArgs() to prevent implicit per-user curl config from bypassing validation

buildCurlProbeSpawnArgs() currently places args (validated) before any curl flags, but curl will still load the default per-user config file (e.g., ~/.curlrc) at startup unless -q/--disable is the first argument. That config can reintroduce request-affecting options (like --config/-K, --data @..., or default headers), undermining the trustedConfigFiles boundary hardening. Prepend -q in the shared argv builder so every probe mode disables implicit config loading.

Suggested fix
 function buildCurlProbeSpawnArgs(
   args: string[],
   url: string,
   bodyFile: string,
   mode: CurlProbeMode,
 ): string[] {
   const outputArgs =
     mode === "json" ? ["-o", bodyFile, "-w", "%{http_code}"] : ["-N", "-o", bodyFile];
   const statusArgs = mode === "chat-stream" ? ["-w", "%{http_code}"] : [];
-  // lgtm[js/file-access-to-http] URL/argv are validated; file-backed config paths must be explicitly trusted.
-  return [...args, ...outputArgs, ...statusArgs, url];
+  // `-q` must be first so curl ignores implicit per-user config files.
+  // lgtm[js/file-access-to-http] URL/argv are validated; file-backed config paths must be explicitly trusted.
+  return ["-q", ...args, ...outputArgs, ...statusArgs, url];
 }
🤖 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 `@src/lib/adapters/http/probe.ts` around lines 243 - 253, The
buildCurlProbeSpawnArgs function must ensure curl's per-user config is disabled
by prepending the "-q"/--disable flag as the very first argv element; modify
buildCurlProbeSpawnArgs so it returns an array that begins with "-q" followed by
the validated args, then outputArgs, statusArgs, and url (i.e., ["-q", ...args,
...outputArgs, ...statusArgs, url]) to guarantee user ~/.curlrc cannot override
probe options.
🤖 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.

Outside diff comments:
In `@src/lib/adapters/http/probe.ts`:
- Around line 243-253: The buildCurlProbeSpawnArgs function must ensure curl's
per-user config is disabled by prepending the "-q"/--disable flag as the very
first argv element; modify buildCurlProbeSpawnArgs so it returns an array that
begins with "-q" followed by the validated args, then outputArgs, statusArgs,
and url (i.e., ["-q", ...args, ...outputArgs, ...statusArgs, url]) to guarantee
user ~/.curlrc cannot override probe options.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: bb128ba7-039b-4c75-aeea-8f182e73a797

📥 Commits

Reviewing files that changed from the base of the PR and between 08b9173 and 3b0456f.

📒 Files selected for processing (10)
  • nemoclaw/src/blueprint/state.test.ts
  • src/lib/actions/uninstall/plan.ts
  • src/lib/adapters/http/probe.test.ts
  • src/lib/adapters/http/probe.ts
  • src/lib/credentials/store.ts
  • src/lib/inference/health.ts
  • src/lib/messaging/channels/slack/hooks/credential-validation.test.ts
  • src/lib/messaging/channels/slack/hooks/credential-validation.ts
  • src/lib/security/credential-filter.test.ts
  • src/lib/security/credential-filter.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/lib/adapters/http/probe.test.ts
  • src/lib/actions/uninstall/plan.ts
  • src/lib/security/credential-filter.ts

@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Selective E2E Results — ✅ All requested jobs passed

Run: 27048339630
Target ref: 3b0456f8b243d3508cff8010beddf263fa7860f3
Workflow ref: main
Requested jobs: cloud-onboard-e2e,onboard-resume-e2e,credential-migration-e2e,credential-sanitization-e2e,inference-routing-e2e,kimi-inference-compat-e2e,messaging-providers-e2e,sandbox-survival-e2e
Summary: 8 passed, 0 failed, 0 skipped

Job Result
cloud-onboard-e2e ✅ success
credential-migration-e2e ✅ success
credential-sanitization-e2e ✅ success
inference-routing-e2e ✅ success
kimi-inference-compat-e2e ✅ success
messaging-providers-e2e ✅ success
onboard-resume-e2e ✅ success
sandbox-survival-e2e ✅ success

@coderabbitai coderabbitai Bot 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.

🧹 Nitpick comments (1)
nemoclaw/src/blueprint/state.test.ts (1)

190-197: ⚡ Quick win

Consider more explicit assertions for clarity.

The optional chaining (write?.path, rename?.from) is defensive, but the test could be clearer by asserting write and rename are defined before accessing their properties.

♻️ Optional: More explicit assertions
+      expect(write).toBeDefined();
+      expect(rename).toBeDefined();
+      expect(write!.path.startsWith(`${STATE_PATH}.${process.pid}.`)).toBe(true);
+      expect(write!.path.endsWith(".tmp")).toBe(true);
+      expect(write!.options).toMatchObject({ mode: 0o600 });
+      expect(rename).toEqual({ from: write!.path, to: STATE_PATH });
+      expect(store.has(write!.path)).toBe(false);
-      const write = writes.at(-1);
-      const rename = renames.at(-1);
-      expect(write?.path.startsWith(`${STATE_PATH}.${process.pid}.`)).toBe(true);
-      expect(write?.path.endsWith(".tmp")).toBe(true);
-      expect(write?.options).toMatchObject({ mode: 0o600 });
-      expect(rename).toEqual({ from: write?.path, to: STATE_PATH });
-      expect(store.has(write?.path || "")).toBe(false);
🤖 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 `@nemoclaw/src/blueprint/state.test.ts` around lines 190 - 197, The test uses
optional chaining on write and rename (from writes.at(-1) and renames.at(-1))
which hides potential undefined values; add explicit assertions that write and
rename are defined before accessing their properties (e.g.,
expect(write).toBeDefined(); expect(rename).toBeDefined()) and then replace or
follow the subsequent expectations that reference write?.path, rename?.from, and
store.get(STATE_PATH) with non-optional accesses to make failures clearer; keep
existing assertions about STATE_PATH, file mode and JSON lastAction intact.
🤖 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.

Nitpick comments:
In `@nemoclaw/src/blueprint/state.test.ts`:
- Around line 190-197: The test uses optional chaining on write and rename (from
writes.at(-1) and renames.at(-1)) which hides potential undefined values; add
explicit assertions that write and rename are defined before accessing their
properties (e.g., expect(write).toBeDefined(); expect(rename).toBeDefined()) and
then replace or follow the subsequent expectations that reference write?.path,
rename?.from, and store.get(STATE_PATH) with non-optional accesses to make
failures clearer; keep existing assertions about STATE_PATH, file mode and JSON
lastAction intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: cb7eb27d-a628-4741-b800-973f5a0dd51a

📥 Commits

Reviewing files that changed from the base of the PR and between 656ec41 and 3ddae22.

📒 Files selected for processing (3)
  • nemoclaw/src/blueprint/state.test.ts
  • src/lib/adapters/http/probe.test.ts
  • src/lib/adapters/http/probe.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/lib/adapters/http/probe.ts
  • src/lib/adapters/http/probe.test.ts

@cv cv merged commit cc854ab into main Jun 6, 2026
35 of 36 checks passed
@cv cv deleted the fix/security-p0-code-scanning branch June 6, 2026 02:31
@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Selective E2E Results — ✅ All requested jobs passed

Run: 27049837542
Target ref: 3ddae224b463a30711d199f40f848fc5a475cbc8
Workflow ref: main
Requested jobs: credential-migration-e2e,credential-sanitization-e2e,inference-routing-e2e,messaging-providers-e2e,cloud-onboard-e2e,sandbox-operations-e2e
Summary: 6 passed, 0 failed, 0 skipped

Job Result
cloud-onboard-e2e ✅ success
credential-migration-e2e ✅ success
credential-sanitization-e2e ✅ success
inference-routing-e2e ✅ success
messaging-providers-e2e ✅ success
sandbox-operations-e2e ✅ success

cv added a commit that referenced this pull request Jun 6, 2026
## Summary
Extracts the curl probe validation and spawn-argument construction
introduced in #4878 into a dedicated helper module, then reuses that
helper from additional host/sandbox probe call sites. This keeps the
SSRF/file-read/multi-transfer policy behind a smaller, auditable
boundary while preserving existing probe behavior and coverage.

## Related Issue
Refs #3654; follow-up to #4878.

## Changes
- Added `src/lib/adapters/http/curl-args.ts` for curl probe URL
validation, option policy, trusted config handling, and spawn argv
construction.
- Updated `src/lib/adapters/http/probe.ts` to import the helper instead
of carrying the policy inline.
- Added `buildValidatedCurlCommandArgs()` for direct curl command
callers that need validation without the `runCurlProbe()` output
wrapper.
- Reused the shared curl policy in host/local probes including wait
helpers, doctor Ollama checks, vLLM/NIM readiness, Ollama
version/runtime/model-size probes, local Ollama probe command
construction, and agent health checks executed through OpenShell.

## Type of Change
- [x] Code change (feature, bug fix, or refactor)
- [ ] Code change with doc updates
- [ ] Doc only (prose changes, no code sample modifications)
- [ ] Doc only (includes code sample changes)

## Verification
- [x] `npx prek run --all-files` passes
- [ ] `npm test` passes
- [ ] Tests added or updated for new or changed behavior
- [x] No secrets, API keys, or credentials committed
- [ ] Docs updated for user-facing behavior changes
- [ ] `npm run docs` builds without warnings (doc changes only)
- [ ] Doc pages follow the [style
guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md)
(doc changes only)
- [ ] New doc pages include SPDX header and frontmatter (new pages only)

`npm test` was attempted, but the existing installer integration test
`test/install-preflight.test.ts > warns on Podman but still runs
onboarding` fails in this environment because host CDI detection turns
the Podman warning path into a missing-CDI issue path before onboarding.
Targeted probe tests and `npm run typecheck:cli` pass.

---
Signed-off-by: Carlos Villela <cvillela@nvidia.com>
@wscurran wscurran added the bug-fix PR fixes a bug or regression label Jun 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug-fix PR fixes a bug or regression security Potential vulnerability, unsafe behavior, or access risk v0.0.61 Release target

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants