diff --git a/agents/langchain-deepagents-code/policy-additions.yaml b/agents/langchain-deepagents-code/policy-additions.yaml index a0a739f47cd..4d46fb65af2 100644 --- a/agents/langchain-deepagents-code/policy-additions.yaml +++ b/agents/langchain-deepagents-code/policy-additions.yaml @@ -17,7 +17,6 @@ filesystem_policy: - /lib - /proc - /dev/urandom - - /app - /etc - /var/log read_write: @@ -28,10 +27,11 @@ filesystem_policy: landlock: # Deep Agents Code is a terminal coding harness, so filesystem policy must - # fail closed when Landlock cannot be applied. `strict` makes the OpenShell - # sandbox startup fail instead of silently degrading if the kernel or workspace - # mount cannot enforce these read-only system paths. - compatibility: strict + # fail closed when Landlock cannot be applied. `hard_requirement` makes the + # OpenShell sandbox startup fail instead of silently degrading if the kernel or + # workspace mount cannot enforce these read-only system paths. Every path + # above must exist in the built image; hard_requirement rejects stale paths. + compatibility: hard_requirement process: run_as_user: sandbox diff --git a/docs/deployment/sandbox-hardening.mdx b/docs/deployment/sandbox-hardening.mdx index f00bf76a961..0cd90aac3ce 100644 --- a/docs/deployment/sandbox-hardening.mdx +++ b/docs/deployment/sandbox-hardening.mdx @@ -129,23 +129,30 @@ System-wide shell hooks that read `/tmp/nemoclaw-proxy-env.sh` source the runtim ### Landlock Kernel Requirements -Landlock LSM requires Linux kernel 5.13 or later with `CONFIG_SECURITY_LANDLOCK=y`. -The NemoClaw sandbox policy uses `compatibility: best_effort`, which means Landlock enforcement is silently skipped on kernels that do not support it. +Landlock first appeared in Linux 5.13 and requires `CONFIG_SECURITY_LANDLOCK=y`. +OpenShell also requires Landlock to be active and the sandbox runtime to permit Landlock syscalls. +OpenClaw and Hermes policies use `compatibility: best_effort`, which continues sandbox startup when Landlock is unavailable. +The pinned OpenShell runtime builds an ABI v2 ruleset, which requires Linux 5.19 or later when the Deep Agents Code policy selects `compatibility: hard_requirement`. +OpenShell therefore aborts Deep Agents Code startup instead of running the agent with reduced filesystem isolation on an older or incompatible runtime. -On such kernels, protection falls back to DAC (file ownership and permissions) only. -Files outside the writable paths would be inaccessible to the agent regardless of DAC permissions. +When Landlock is unavailable, OpenClaw and Hermes continue without the configured Landlock path restrictions. +Container mounts and DAC file ownership and permissions still apply. +Deep Agents Code onboarding returns a nonzero exit and does not report the terminal runtime as ready. +The fail-closed behavior also applies when OpenShell cannot open a configured policy path or enforce the prepared ruleset. -Verify Landlock availability: +OpenShell probes the actual sandbox runtime during startup instead of relying on a host filesystem path. +For a running sandbox, inspect its startup events: ```bash -ls /sys/kernel/security/landlock +openshell logs -n 100 --source sandbox ``` -On a kernel with Landlock support, the path exists and `ls` succeeds. -If it reports `No such file or directory`, the kernel does not expose Landlock, and the sandbox falls back to DAC-only enforcement as described above. +OpenShell reports successful Landlock preparation with `Applying Landlock filesystem sandbox [abi: compat: ro: rw:]` followed by `Landlock ruleset built [rules_applied: skipped:]`. +If `best_effort` cannot prepare or enforce Landlock, OpenShell emits a high-severity finding with `Landlock filesystem sandbox unavailable: ` or `Landlock restrict_self failed (best_effort): ` and continues, while either failure prevents Deep Agents Code from starting. -For production deployments, use kernel 5.13+ with Landlock enabled. -The `test/e2e/e2e-cloud-experimental/checks/04-landlock-readonly.sh` script validates enforcement at runtime. +For Deep Agents Code production deployments, use Linux 5.19 or later with Landlock enabled and its syscalls permitted. +OpenClaw and Hermes can apply the access rights available from Linux 5.13 onward in `best_effort` mode. +The `test/e2e/e2e-cloud-experimental/checks/04-landlock-readonly.sh` and `test/e2e/e2e-cloud-experimental/checks/05-deepagents-code-landlock-readonly.sh` scripts validate enforcement at runtime. ## References diff --git a/docs/reference/enterprise-readiness.mdx b/docs/reference/enterprise-readiness.mdx index 82c3015a873..2f2433f1da3 100644 --- a/docs/reference/enterprise-readiness.mdx +++ b/docs/reference/enterprise-readiness.mdx @@ -119,7 +119,7 @@ Each one includes the current workaround or next step. | Controls bypassed outside the managed gateway path | Network policy and inference auth are not enforced if a runtime starts outside the NemoClaw-managed entrypoint. | Use NemoClaw-managed onboarding and sandbox entrypoints for production workflows. Refer to [Known Limitations](../security/best-practices#known-limitations). | | One consumer per messaging bot token | Two sandboxes sharing a bot token disconnect each other and drop messages. | Use a distinct bot token per sandbox. Refer to the messaging troubleshooting in [Troubleshooting](troubleshooting#messaging-bridge-appears-running-but-no-messages-arrive). | | In-sandbox config edits do not persist | Direct edits to agent config inside the running sandbox do not survive rebuilds. | Make durable config changes from the host by re-running `$$nemoclaw onboard`, not inside the sandbox. Refer to [Troubleshooting](troubleshooting). | -| Landlock filesystem enforcement degrades on old kernels | Filesystem restrictions fall back to container mounts below Linux kernel 5.13. | Run on kernel 5.13 or later for full enforcement. Refer to [Landlock LSM Enforcement](../security/best-practices#landlock-lsm-enforcement). | +| Landlock failure handling varies by agent | OpenClaw and Hermes continue with reduced filesystem isolation when Landlock cannot be fully applied, while Deep Agents Code fails sandbox startup. | Run Deep Agents Code on Linux 5.19 or later with Landlock enabled and its syscalls permitted, resolve any reported policy-path error, keep its policy at `hard_requirement`, and refer to [Landlock LSM Enforcement](../security/best-practices#landlock-lsm-enforcement). | | Best-effort capability and resource limits | Capability drops and ulimits skip silently when the runtime blocks them. | Pass `--cap-drop=ALL` and `--ulimit` at the container runtime, or set `NEMOCLAW_REQUIRE_CAP_DROP=1` to fail closed. Refer to [Process Controls](../security/best-practices#process-controls). | ## Field Conversation Guidance diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 33ed0d7b9eb..94d2a5b0d64 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -1203,7 +1203,8 @@ nemo-deepagents rebuild ``` If startup reports that Landlock enforcement is unavailable, the Deep Agents sandbox fails closed instead of running with reduced filesystem enforcement. -Deep Agents uses `compatibility: strict` for its managed filesystem policy, so kernels older than 5.13 or VM-backed Docker runtimes without Landlock support can block sandbox creation. +Deep Agents uses `compatibility: hard_requirement` for its managed filesystem policy. +The pinned OpenShell runtime builds a Landlock ABI v2 ruleset, so Linux kernels older than 5.19 or VM-backed Docker runtimes without Landlock support can block sandbox creation. Move the sandbox to a Linux kernel and container runtime that support Landlock, then rerun onboarding or rebuild the sandbox. @@ -1859,8 +1860,11 @@ To fix, run `$$nemoclaw destroy` on whichever sandbox should sto ### Landlock filesystem restrictions silently degraded -After sandbox creation, NemoClaw checks whether the host kernel supports Landlock (Linux 5.13+). -If the kernel is too old or you are running on macOS (where the Docker VM kernel may lack Landlock), a warning prints: +OpenShell applies Landlock inside the sandbox runtime, whose kernel and syscall restrictions can differ from the CLI host on Docker Desktop or a remote gateway. +Landlock requires Linux 5.13 or later, an enabled Landlock LSM, and permission to use the Landlock syscalls. + +OpenClaw and Hermes use `best_effort` compatibility. +After creating one of these sandboxes, NemoClaw compares the Linux kernel version or Docker Desktop VM kernel version with 5.13 and prints a warning when it is too old: ```text ⚠ Landlock: Docker VM kernel does not support Landlock (requires ≥5.13). @@ -1868,22 +1872,36 @@ If the kernel is too old or you are running on macOS (where the Docker VM kernel ``` This warning is informational and does not block sandbox creation. -The sandbox runs without kernel-level filesystem restrictions, relying on container mount configuration instead. -For full filesystem enforcement, run on a Linux kernel 5.13 or later (Ubuntu 22.04 LTS and later include Landlock support). +The version check is preliminary because OpenShell's startup probe determines whether the sandbox runtime can use Landlock. +Under `best_effort`, an unavailable Landlock runtime removes the kernel-level filesystem restrictions, while an inaccessible configured path is skipped and the remaining rules are applied. +Container mounts and DAC permissions continue to apply. + +Inspect the authoritative OpenShell startup events for a running sandbox: + +```bash +openshell logs -n 100 --source sandbox +``` + +OpenClaw and Hermes can apply the access rights available from Linux 5.13 onward in `best_effort` mode. ### Landlock filesystem policy blocks sandbox startup -Deep Agents uses strict Landlock compatibility. -If the host kernel, Docker VM, or sandbox filesystem mount cannot enforce the managed read-only policy, OpenShell refuses to start the sandbox instead of silently degrading. +Deep Agents Code uses `hard_requirement` compatibility. +The pinned OpenShell runtime builds a Landlock ABI v2 ruleset, so this mode requires Linux 5.19 or later in addition to an enabled LSM and permitted syscalls. +If OpenShell detects that Landlock is unavailable, cannot open a configured policy path, or cannot enforce the prepared ruleset, sandbox startup fails and `$$nemoclaw onboard` returns a nonzero exit instead of reporting the terminal runtime as ready. +NemoClaw does not automatically delete a failed sandbox from create-stream text because that output does not carry an authoritative resource identity. +If OpenShell retains the failed resource, inspect it with `openshell sandbox get ` and delete it only after confirming that it belongs to the failed attempt. +Do not replace `hard_requirement` with `best_effort` to bypass this failure. -Run Deep Agents on a Linux kernel 5.13 or later with a container runtime that exposes Landlock to the sandbox. -After moving to a compatible host or runtime, rerun onboarding or rebuild the sandbox: +Review the onboarding error first because a policy-path failure names the inaccessible path. +Use Linux 5.19 or later with Landlock enabled, ensure the sandbox runtime permits Landlock syscalls, and correct any missing or inaccessible path reported by OpenShell. +After fixing the host, runtime, or policy path, rerun onboarding or rebuild the sandbox: ```bash -nemo-deepagents rebuild +$$nemoclaw rebuild ``` diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 439acaf65f5..45853090831 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -275,7 +275,7 @@ The container mounts system directories read-only to prevent the agent from modi | Aspect | Detail | |---|---| -| Default | `/usr`, `/lib`, `/proc`, `/dev/urandom`, `/app`, `/etc`, `/var/log` are read-only. | +| Default | System paths such as `/usr`, `/lib`, `/proc`, `/dev/urandom`, `/etc`, and `/var/log` are read-only. Agent policies can add image-specific paths such as `/app`; Deep Agents Code omits paths that its image does not provide because `hard_requirement` treats a missing path as fatal. | | What you can change | Add or remove paths in the `filesystem_policy.read_only` section of the policy file. | | Risk if relaxed | Making `/usr` or `/lib` writable lets the agent replace system binaries (such as `curl` or `node`) with trojanized versions. Making `/etc` writable lets the agent modify DNS resolution, TLS trust stores, or user accounts. | | Recommendation | Never make system paths writable. If the agent needs a writable location for generated files, use a subdirectory of `/sandbox`. | @@ -391,10 +391,13 @@ Landlock is a Linux Security Module that enforces filesystem access rules at the | Aspect | Detail | |---|---| -| Default | `compatibility: best_effort`. The entrypoint applies Landlock rules when the kernel supports them and silently skips them on older kernels. | +| Default | OpenClaw and Hermes use `compatibility: best_effort`, while Deep Agents Code uses `compatibility: hard_requirement` so OpenShell aborts sandbox startup when Landlock cannot be fully applied. | | What you can change | This is a NemoClaw default, not a user-facing knob. | -| Risk if relaxed | On kernels without Landlock support (pre-5.13), filesystem restrictions rely solely on container mount configuration, which is less granular. | -| Recommendation | Run on a kernel that supports Landlock (5.13+). Ubuntu 22.04 LTS and later include Landlock support. | +| Risk if relaxed | A `best_effort` sandbox that cannot use Landlock relies on container mounts and DAC permissions, which are less granular than the configured filesystem policy. | +| Recommendation | Run Deep Agents Code on Linux 5.19 or later with Landlock enabled and its syscalls permitted. OpenClaw and Hermes can apply the access rights available from Linux 5.13 onward in `best_effort` mode. Treat a Deep Agents Code startup failure as an unsupported runtime or policy-path error instead of changing its policy to `best_effort`. | + +Landlock policy is fixed when a sandbox is created. +Rebuild existing Deep Agents Code sandboxes after upgrading NemoClaw to apply the hard-enforcement policy. @@ -411,10 +414,10 @@ Landlock is a Linux Security Module that enforces filesystem access rules at the | Aspect | Detail | |---|---| -| Default | `compatibility: strict`. Deep Agents sandbox startup fails closed when OpenShell cannot enforce the managed filesystem policy. | +| Default | `compatibility: hard_requirement`. Deep Agents sandbox startup fails closed when OpenShell cannot enforce the managed filesystem policy. | | What you can change | This is a NemoClaw Deep Agents invariant, not a user-facing knob. | | Risk if relaxed | Silent Landlock degradation would leave the terminal coding harness with weaker filesystem isolation while still reporting a successful sandbox. | -| Recommendation | Run Deep Agents on a kernel and runtime that support Landlock enforcement. Rebuild or move hosts if startup reports an enforcement failure. | +| Recommendation | Run Deep Agents on Linux 5.19 or later with Landlock enabled and its syscalls permitted. Rebuild or move hosts if startup reports an enforcement failure. | diff --git a/schemas/sandbox-policy.schema.json b/schemas/sandbox-policy.schema.json index 7e3851449d6..97cd4a46aa4 100644 --- a/schemas/sandbox-policy.schema.json +++ b/schemas/sandbox-policy.schema.json @@ -30,7 +30,7 @@ "properties": { "compatibility": { "type": "string", - "enum": ["strict", "best_effort"] + "enum": ["best_effort", "hard_requirement"] } } }, diff --git a/src/lib/build-context.test.ts b/src/lib/build-context.test.ts index 86d906f0984..ca074330a68 100644 --- a/src/lib/build-context.test.ts +++ b/src/lib/build-context.test.ts @@ -79,6 +79,20 @@ describe("printSandboxCreateRecoveryHints", () => { expect(stderr()).toContain("reached the gateway"); }); + it("prints fail-closed guidance for a hard Landlock startup failure", () => { + printSandboxCreateRecoveryHints( + "Created sandbox: dcode\nLandlock path unavailable in hard_requirement mode: /app (read_only): No such file or directory", + ); + + const out = stderr(); + expect(out).toContain("could not apply required Landlock filesystem isolation"); + expect(out).toContain("Linux 5.19 or later (Landlock ABI v2)"); + expect(out).toContain("enable the Landlock LSM"); + expect(out).toContain("allow its syscalls"); + expect(out).toContain("unavailable filesystem path"); + expect(out).toContain("onboard --resume"); + }); + // Manual / ARM64 E2E note (#3266): // // The misleading "failed to upload image tar into container" Docker 404 only diff --git a/src/lib/build-context.ts b/src/lib/build-context.ts index 2af7c78adcc..071fe4d0420 100644 --- a/src/lib/build-context.ts +++ b/src/lib/build-context.ts @@ -197,6 +197,17 @@ export function printSandboxCreateRecoveryHints( console.error(" If this repeats, restart Docker or the gateway and retry."); return; } + if (failure.kind === "landlock_enforcement_failed") { + console.error(" Hint: OpenShell could not apply required Landlock filesystem isolation."); + console.error( + " Deep Agents Code fails closed when the runtime lacks Landlock support or a", + ); + console.error(" hard-required policy path is absent from the sandbox image."); + console.error(" Fix: use Linux 5.19 or later (Landlock ABI v2), enable the Landlock LSM,"); + console.error(" allow its syscalls, and correct any unavailable filesystem path above."); + console.error(` Recovery: ${CLI_NAME} onboard --resume`); + return; + } if (failure.kind === "sandbox_create_incomplete") { console.error(" Hint: sandbox creation started but the create stream did not finish cleanly."); console.error(` Recovery: ${CLI_NAME} onboard --resume`); diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 7b0c7de17a1..19cbd04ddbc 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -261,7 +261,7 @@ const resumeProviderShim = require("./onboard/resume-provider-shim"); const hermesProviderAuth = require("./hermes-provider-auth"); const onboardHermesDashboard: typeof import("./onboard/hermes-dashboard") = require("./onboard/hermes-dashboard"); const hermesAuth: typeof import("./onboard/hermes-auth") = require("./onboard/hermes-auth"); -const { warnIfLandlockUnsupported } = require("./onboard/landlock-warning"); +const { warnIfManagedAgentLandlockUnsupported } = require("./onboard/landlock-warning"); const { HERMES_AUTH_METHOD_API_KEY, HERMES_AUTH_METHOD_OAUTH, @@ -2954,7 +2954,7 @@ async function createSandboxWithBaseImageResolution( console.log(` ✓ Sandbox '${sandboxName}' created`); - warnIfLandlockUnsupported({ dockerInfoFormat, runCapture }); + warnIfManagedAgentLandlockUnsupported(isManagedDcodeAgent, dockerInfoFormat, runCapture); // #4614: arm rollback only when the sandbox was not live before (never a recreate/rebuild). if (!liveExists) sandboxCancelRollback.arm(sandboxName); diff --git a/src/lib/onboard/created-sandbox-failure.test.ts b/src/lib/onboard/created-sandbox-failure.test.ts index 69fe3edc5d2..94d26227417 100644 --- a/src/lib/onboard/created-sandbox-failure.test.ts +++ b/src/lib/onboard/created-sandbox-failure.test.ts @@ -76,10 +76,35 @@ describe("reportSandboxCreateFailure", () => { expect(deps.printRecoveryHints).toHaveBeenCalledWith("boom", { createArgs: ["sandbox", "create", "alpha"], }); + expect( + (deps.printCreateFailureDiagnostics as ReturnType).mock.invocationCallOrder[0], + ).toBeLessThan( + (deps.printRecoveryHints as ReturnType).mock.invocationCallOrder[0], + ); expect(deps.exitProcess).toHaveBeenCalledWith(42); expect(deps.warn).not.toHaveBeenCalled(); }); + it("preserves recovery hints and exit status when diagnostics collection fails", () => { + const deps = createFailureDeps({ + printCreateFailureDiagnostics: vi.fn(() => { + throw new Error("diagnostics disk unavailable"); + }), + }); + + expect(() => + reportSandboxCreateFailure(createFailureOptions({ createStatus: 47 }), deps), + ).toThrow(ExitSignal); + + expect(deps.printRecoveryHints).toHaveBeenCalledWith("boom", { + createArgs: ["sandbox", "create", "alpha"], + }); + expect(deps.error).toHaveBeenCalledWith( + " Could not save sandbox failure diagnostics; continuing recovery.", + ); + expect(deps.exitProcess).toHaveBeenCalledWith(47); + }); + it("redacts create output before classification and echoing", () => { // With output: leading blank + headline + blank + output echo + "Try:" hint = 5 error() calls. const withOutput = createFailureDeps(); diff --git a/src/lib/onboard/created-sandbox-failure.ts b/src/lib/onboard/created-sandbox-failure.ts index 820c4fddbdd..50fdd335837 100644 --- a/src/lib/onboard/created-sandbox-failure.ts +++ b/src/lib/onboard/created-sandbox-failure.ts @@ -54,9 +54,13 @@ export function reportSandboxCreateFailure( deps.error(""); deps.error(redactedCreateOutput); } - deps.printCreateFailureDiagnostics(options.sandboxName, { - backupPath: options.restoreBackupPath, - }); + try { + deps.printCreateFailureDiagnostics(options.sandboxName, { + backupPath: options.restoreBackupPath, + }); + } catch { + deps.error(" Could not save sandbox failure diagnostics; continuing recovery."); + } deps.error(" Try: openshell sandbox list # check gateway state"); deps.printRecoveryHints(redactedCreateOutput, { createArgs: options.createArgs }); return deps.exitProcess(options.createStatus === 0 ? 1 : options.createStatus); diff --git a/src/lib/onboard/initial-policy-real-policy.test.ts b/src/lib/onboard/initial-policy-real-policy.test.ts index b3fe25a6f9b..05f44af37c5 100644 --- a/src/lib/onboard/initial-policy-real-policy.test.ts +++ b/src/lib/onboard/initial-policy-real-policy.test.ts @@ -30,7 +30,13 @@ type PolicyEntry = { }; type PolicyDocument = { - filesystem_policy?: { read_write?: string[] }; + filesystem_policy?: { + read_only?: string[]; + read_write?: string[]; + }; + landlock?: { + compatibility?: string; + }; network_policies?: Record; }; @@ -55,6 +61,37 @@ function readPreparedPolicy(prepared: { } describe("initial sandbox policy real preset merge", () => { + it("preserves fail-closed Landlock while composing the DCode create policy (#5795)", () => { + const prepared = prepareInitialSandboxCreatePolicy( + repoPath("agents", "langchain-deepagents-code", "policy-additions.yaml"), + [], + { + additionalPresets: ["tavily"], + agentName: "langchain-deepagents-code", + }, + ); + const policy = readPreparedPolicy(prepared); + + expect(prepared.appliedPresets).toEqual(["tavily"]); + expect(policy.landlock?.compatibility).toBe("hard_requirement"); + expect(policy.filesystem_policy?.read_only).toEqual([ + "/usr", + "/opt/venv", + "/lib", + "/proc", + "/dev/urandom", + "/etc", + "/var/log", + ]); + expect(policy.filesystem_policy?.read_write).toEqual([ + "/sandbox", + "/sandbox/.deepagents", + "/tmp", + "/dev/null", + ]); + expect(policy.network_policies).toHaveProperty("tavily"); + }); + it("uses Hermes channel YAML when the Hermes base policy path implies the agent", () => { const prepared = prepareInitialSandboxCreatePolicy( repoPath("agents", "hermes", "policy-additions.yaml"), diff --git a/src/lib/onboard/landlock-warning.test.ts b/src/lib/onboard/landlock-warning.test.ts new file mode 100644 index 00000000000..f2e8634314c --- /dev/null +++ b/src/lib/onboard/landlock-warning.test.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { warnIfLandlockUnsupported } from "./landlock-warning"; + +describe("post-create Landlock warning", () => { + it("warns when a best-effort sandbox uses an old Linux kernel", () => { + const warn = vi.fn(); + + warnIfLandlockUnsupported({ + compatibility: "best_effort", + platform: "linux", + dockerInfoFormat: vi.fn(() => ""), + runCapture: vi.fn(() => "5.4.0-216-generic"), + warn, + }); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining("does not support Landlock")); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("best_effort mode")); + }); + + it("does not contradict a successful hard-required DCode startup (#5795)", () => { + const dockerInfoFormat = vi.fn(() => "5.4.0"); + const runCapture = vi.fn(() => "5.4.0"); + const warn = vi.fn(); + + warnIfLandlockUnsupported({ + compatibility: "hard_requirement", + platform: "linux", + dockerInfoFormat, + runCapture, + warn, + }); + + expect(dockerInfoFormat).not.toHaveBeenCalled(); + expect(runCapture).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + }); + + it("does not probe a macOS Docker VM after hard-required startup succeeds (#5795)", () => { + const dockerInfoFormat = vi.fn(() => "5.4.0"); + const runCapture = vi.fn(() => "5.4.0"); + const warn = vi.fn(); + + warnIfLandlockUnsupported({ + compatibility: "hard_requirement", + platform: "darwin", + dockerInfoFormat, + runCapture, + warn, + }); + + expect(dockerInfoFormat).not.toHaveBeenCalled(); + expect(runCapture).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/onboard/landlock-warning.ts b/src/lib/onboard/landlock-warning.ts index bc1458c2b64..dbaaa2a17e8 100644 --- a/src/lib/onboard/landlock-warning.ts +++ b/src/lib/onboard/landlock-warning.ts @@ -16,17 +16,31 @@ function warnIfUnsupported(kernel: string, label: string, warn: (message: string warn(" Sandbox filesystem restrictions will silently degrade (best_effort mode)."); } +/** + * Emits host/VM kernel warnings only for best-effort agents. + * + * compatibility must come from the loaded agent manifest. For hard_requirement + * agents, OpenShell's sandbox-runtime probe is authoritative and this helper + * intentionally avoids a host-kernel heuristic. + */ export function warnIfLandlockUnsupported({ + compatibility, platform = process.platform, dockerInfoFormat, runCapture, warn = console.warn, }: { + compatibility: "best_effort" | "hard_requirement"; platform?: NodeJS.Platform; dockerInfoFormat: (format: string, options?: { ignoreError?: boolean }) => string; runCapture: (args: string[], options?: { ignoreError?: boolean }) => string; warn?: (message: string) => void; }): void { + // A successful hard_requirement startup is already authoritative proof that + // OpenShell applied Landlock in the sandbox runtime. A host-side version + // heuristic can inspect a different kernel and must not claim degradation. + if (compatibility === "hard_requirement") return; + try { if (platform === "darwin") { const vmKernel = dockerInfoFormat("{{.KernelVersion}}", { ignoreError: true }).trim(); @@ -39,3 +53,15 @@ export function warnIfLandlockUnsupported({ /* best effort warning */ } } + +export function warnIfManagedAgentLandlockUnsupported( + isManagedDcodeAgent: boolean, + dockerInfoFormat: (format: string, options?: { ignoreError?: boolean }) => string, + runCapture: (args: string[], options?: { ignoreError?: boolean }) => string, +): void { + warnIfLandlockUnsupported({ + compatibility: isManagedDcodeAgent ? "hard_requirement" : "best_effort", + dockerInfoFormat, + runCapture, + }); +} diff --git a/src/lib/onboard/sandbox-create-failure.ts b/src/lib/onboard/sandbox-create-failure.ts index c05df67492b..e8e872f58cb 100644 --- a/src/lib/onboard/sandbox-create-failure.ts +++ b/src/lib/onboard/sandbox-create-failure.ts @@ -5,6 +5,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { redactFull } from "../security/redact"; + const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g; const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; const MAX_RELEVANT_LOG_LINES = 120; @@ -122,11 +124,12 @@ function latestFieldValue(lines: string[], field: string): string | null { return null; } -function copyFileIfPresent(src: string | null, dst: string): string | null { +function copyRedactedTextFileIfPresent(src: string | null, dst: string): string | null { if (!src) return null; try { if (!fs.existsSync(src)) return null; - fs.copyFileSync(src, dst); + const content = fs.readFileSync(src, "utf8"); + fs.writeFileSync(dst, redactFull(stripAnsi(content)), { mode: 0o600 }); return dst; } catch { return null; @@ -177,30 +180,34 @@ export function collectSandboxCreateFailureDiagnostics( rawLines && relevantLines.length === 0 ? rawLines.filter((line) => line.trim()).slice(-MAX_GATEWAY_TAIL_LINES) : []; + const redactedRelevantLines = relevantLines.map((line) => redactFull(line)); + const redactedGatewayTailLines = gatewayTailLines.map((line) => redactFull(line)); const stateDir = latestFieldValue(relevantLines, "state_dir"); const consoleOutput = latestFieldValue(relevantLines, "console_output") ?? (stateDir ? path.join(stateDir, "rootfs-console.log") : null); - const copiedConsoleOutput = copyFileIfPresent( + const copiedConsoleOutput = copyRedactedTextFileIfPresent( consoleOutput, path.join(dir, "rootfs-console.log"), ); const stateEntries = listStateDir(stateDir); const backupPath = options.backupPath ?? null; - if (relevantLines.length > 0) { + if (redactedRelevantLines.length > 0) { fs.writeFileSync( path.join(dir, "openshell-gateway-relevant.log"), - `${relevantLines.join("\n")}\n`, + `${redactedRelevantLines.join("\n")}\n`, { mode: 0o600, }, ); } const gatewayTailPath = - gatewayTailLines.length > 0 ? path.join(dir, "openshell-gateway-tail.log") : null; + redactedGatewayTailLines.length > 0 ? path.join(dir, "openshell-gateway-tail.log") : null; if (gatewayTailPath) { - fs.writeFileSync(gatewayTailPath, `${gatewayTailLines.join("\n")}\n`, { mode: 0o600 }); + fs.writeFileSync(gatewayTailPath, `${redactedGatewayTailLines.join("\n")}\n`, { + mode: 0o600, + }); } const summaryLines = [ `created_at=${now.toISOString()}`, @@ -212,10 +219,10 @@ export function collectSandboxCreateFailureDiagnostics( `console_output=${consoleOutput ?? "unknown"}`, `copied_console_output=${copiedConsoleOutput ?? "not-copied"}`, `backup_path=${backupPath ?? "none"}`, - ]; + ].map((line) => redactFull(line)); if (stateEntries.length > 0) { summaryLines.push("state_dir_entries:"); - summaryLines.push(...stateEntries.map((entry) => ` ${entry}`)); + summaryLines.push(...stateEntries.map((entry) => redactFull(` ${entry}`))); } fs.writeFileSync(path.join(dir, "summary.txt"), `${summaryLines.join("\n")}\n`, { mode: 0o600, @@ -230,7 +237,10 @@ export function collectSandboxCreateFailureDiagnostics( copiedConsoleOutput, gatewayTailPath, backupPath, - summaryLines: relevantLines.length > 0 ? relevantLines.slice(-8) : gatewayTailLines.slice(-8), + summaryLines: + redactedRelevantLines.length > 0 + ? redactedRelevantLines.slice(-8) + : redactedGatewayTailLines.slice(-8), }; } @@ -249,7 +259,7 @@ export function printSandboxCreateFailureDiagnostics( } } if (diagnostics.backupPath) { - console.error(` State backup retained: ${diagnostics.backupPath}`); + console.error(` State backup retained: ${redactFull(diagnostics.backupPath)}`); } return diagnostics; } diff --git a/src/lib/onboard/sandbox-gpu-create-flow.test.ts b/src/lib/onboard/sandbox-gpu-create-flow.test.ts index dc0a63b01d7..f2018cd869f 100644 --- a/src/lib/onboard/sandbox-gpu-create-flow.test.ts +++ b/src/lib/onboard/sandbox-gpu-create-flow.test.ts @@ -34,7 +34,8 @@ vi.mock("./docker-gpu-sandbox-create", () => ({ createDockerGpuSandboxCreatePatch: mocks.createDockerGpuSandboxCreatePatch, })); -vi.mock("./sandbox-create-failure", () => ({ +vi.mock("./sandbox-create-failure", async (importOriginal) => ({ + ...(await importOriginal()), printSandboxCreateFailureDiagnostics: mocks.printSandboxCreateFailureDiagnostics, })); @@ -356,6 +357,41 @@ describe("runSandboxGpuCreateFlow native failure and readiness", () => { expect(output).not.toContain("super-secret-create-value"); }); + it("does not delete a same-name sandbox from hard-required Landlock create output (#5795)", async () => { + mocks.streamSandboxCreate.mockResolvedValueOnce({ + status: 17, + output: [ + "Created sandbox: alpha", + "Landlock filesystem sandbox unavailable (hard_requirement, will fail)", + "Failed to prepare sandbox: incompatible directory-only access-rights: ReadDir", + ].join("\n"), + sawProgress: true, + }); + const deps = createDeps(); + const exit = mockExit(17); + + await expect(runSandboxGpuCreateFlow(createInput(), deps)).rejects.toThrow("process.exit:17"); + + expect(deps.runOpenshell).not.toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(17); + expect(mocks.waitForCreatedSandboxReadyWithTrace).not.toHaveBeenCalled(); + }); + + it("does not delete a same-name sandbox without exact create evidence (#5795)", async () => { + mocks.streamSandboxCreate.mockResolvedValueOnce({ + status: 17, + output: "Landlock unavailable in hard_requirement mode: not enabled in the active LSM set", + sawProgress: false, + }); + const deps = createDeps(); + mockExit(17); + + await expect(runSandboxGpuCreateFlow(createInput(), deps)).rejects.toThrow("process.exit:17"); + + expect(deps.runOpenshell).not.toHaveBeenCalled(); + expect(mocks.waitForCreatedSandboxReadyWithTrace).not.toHaveBeenCalled(); + }); + it("does not retry compatibility for a non-GPU native readiness failure (#6110)", async () => { mockReadinessFailure(); const deps = createDeps(); diff --git a/src/lib/validation.sandbox-create-landlock.test.ts b/src/lib/validation.sandbox-create-landlock.test.ts new file mode 100644 index 00000000000..de331b2d8bb --- /dev/null +++ b/src/lib/validation.sandbox-create-landlock.test.ts @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { classifySandboxCreateFailure } from "./validation"; + +describe("classifySandboxCreateFailure Landlock failures", () => { + it("classifies the pinned OpenShell 0.0.72 hard_requirement missing-path output", () => { + const output = fs.readFileSync( + path.resolve( + "test/fixtures/openshell-0.0.72-landlock-hard-requirement-missing-read-only-path.log", + ), + "utf8", + ); + + const result = classifySandboxCreateFailure(output); + + expect(result.kind).toBe("landlock_enforcement_failed"); + expect(result.uploadedToGateway).toBe(true); + }); + + it.each([ + "Landlock unavailable in hard_requirement mode: not implemented (kernel lacks CONFIG_SECURITY_LANDLOCK)", + 'Landlock path unavailable in hard_requirement mode: /app (path does not exist): failed to open "/app": No such file or directory (os error 2)', + "Landlock filesystem sandbox unavailable (hard_requirement, will fail): ABI v1 below required ABI v2\nFailed to prepare sandbox: partially incompatible access-rights: Refer", + "Landlock filesystem sandbox unavailable (hard_requirement, will fail): ABI v1 below required ABI v2\nFailed to prepare sandbox: failed to create a ruleset: Operation not permitted (os error 1)", + "Landlock filesystem sandbox unavailable (hard_requirement, will fail): ABI v1 below required ABI v2\nFailed to prepare sandbox: failed to add a rule: Invalid argument (os error 22)", + "Landlock filesystem sandbox unavailable (hard_requirement, will fail): ABI v1 below required ABI v2\nFailed to prepare sandbox: failed to set no_new_privs: Operation not permitted (os error 1)", + "Landlock filesystem sandbox unavailable (hard_requirement, will fail): ABI v1 below required ABI v2\nFailed to prepare sandbox: failed to restrict the calling thread: Operation not permitted (os error 1)", + ])("detects a hard-required Landlock enforcement failure: %s", (message) => { + const result = classifySandboxCreateFailure(`Created sandbox: test\n${message}`); + + expect(result.kind).toBe("landlock_enforcement_failed"); + expect(result.uploadedToGateway).toBe(true); + }); + + it("does not infer an uploaded sandbox from a pre-create hard Landlock error", () => { + const result = classifySandboxCreateFailure( + "Landlock unavailable in hard_requirement mode: not enabled in the active LSM set", + ); + + expect(result.kind).toBe("landlock_enforcement_failed"); + expect(result.uploadedToGateway).toBe(false); + }); + + it.each([ + "Landlock filesystem sandbox unavailable: partially incompatible access-rights: Refer", + "Landlock restrict_self failed (best_effort): failed to restrict the calling thread: EPERM", + ])("does not classify a best-effort Landlock warning as fatal: %s", (message) => { + expect(classifySandboxCreateFailure(message).kind).toBe("unknown"); + }); + + it.each([ + "Failed to set no_new_privs: Operation not permitted (os error 1)", + "failed to set no_new_privs: Operation not permitted (os error 1)", + "failed to restrict the calling thread: Operation not permitted (os error 1)", + "failed to create a ruleset: unrelated build tool failure", + "failed to add a rule: unrelated policy engine failure", + "Failed to prepare supervisor identity isolation: failed to create a ruleset", + "Failed to prepare sandbox: failed to create a ruleset: unrelated build tool error", + "Failed to prepare sandbox: failed to set no_new_privs: Operation not permitted", + ])("does not classify a non-Landlock sandbox-create error as Landlock: %s", (message) => { + expect(classifySandboxCreateFailure(`Created sandbox: test\n${message}`).kind).toBe( + "sandbox_create_incomplete", + ); + }); +}); diff --git a/src/lib/validation.ts b/src/lib/validation.ts index bfca2559b73..2cf211e0e94 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -17,6 +17,7 @@ export interface SandboxCreateFailure { | "image_transfer_timeout" | "image_transfer_reset" | "image_upload_container_missing" + | "landlock_enforcement_failed" | "sandbox_create_incomplete" | "tls_cert_mismatch" | "gpu_cdi_injection_failed" @@ -135,6 +136,40 @@ export function classifySandboxCreateFailure(output = ""): SandboxCreateFailure ) { return { kind: "gpu_cdi_injection_failed", uploadedToGateway }; } + // OpenShell 0.0.72 emits the explicit hard_requirement errors below when + // Landlock is unavailable or a path cannot be opened. It can also wrap raw + // Landlock preparation errors with "Failed to prepare sandbox:"; require a + // Landlock/hard_requirement marker elsewhere in the output so unrelated + // ruleset, seccomp, or no_new_privs failures stay on the generic path. + // Best-effort warnings are excluded because they do not abort startup. + const explicitHardRequirementFailure = + /Landlock (?:path )?unavailable in hard_requirement mode:/i.test(text); + const hardRequiredLandlockContext = /(?:Landlock|hard_requirement)/i.test(text); + const bestEffortLandlockWarning = + /Landlock filesystem sandbox unavailable:|Landlock restrict_self failed \(best_effort\):/i.test( + text, + ); + const wrappedHardRequirementPreparationFailure = + !bestEffortLandlockWarning && + hardRequiredLandlockContext && + /Failed to prepare sandbox:[^\r\n]*(?:(?:fully|partially) incompatible access-rights|failed to (?:create a ruleset|add a rule|check file descriptor type)|access-rights not handled by the ruleset|incompatible directory-only access-rights)/i.test( + text, + ); + const hardRequirementEnforcementFailure = + !bestEffortLandlockWarning && + hardRequiredLandlockContext && + (/failed to restrict the calling thread:/i.test(text) || + /failed to set no_new_privs:/.test(text)); + if ( + explicitHardRequirementFailure || + wrappedHardRequirementPreparationFailure || + hardRequirementEnforcementFailure + ) { + return { + kind: "landlock_enforcement_failed", + uploadedToGateway: uploadedToGateway || /Created sandbox:/i.test(text), + }; + } // Require BOTH the failed Docker command block containing the plugin-install // step AND npm-prefixed network evidence for the same plugin package. Docker // prints subprocess stderr before its final failed-command summary, so a diff --git a/test/agent-variant-docs.test.ts b/test/agent-variant-docs.test.ts index 97adf0865b3..b0368575b6b 100644 --- a/test/agent-variant-docs.test.ts +++ b/test/agent-variant-docs.test.ts @@ -132,7 +132,7 @@ OpenClaw content. expect(rendered).toContain("![Diagram](../../../manage-sandboxes/images/diagram.png)"); }); - it("renders strict Landlock troubleshooting for Deep Agents only", () => { + it("renders hard-requirement Landlock troubleshooting for Deep Agents only", () => { const troubleshooting = readFileSync( new URL("../docs/reference/troubleshooting.mdx", import.meta.url), "utf8", @@ -145,9 +145,9 @@ OpenClaw content. }); expect(deepAgents).toContain("### Landlock filesystem policy blocks sandbox startup"); - expect(deepAgents).toContain("Deep Agents uses strict Landlock compatibility."); + expect(deepAgents).toContain("Deep Agents Code uses `hard_requirement` compatibility."); expect(deepAgents).toContain( - "OpenShell refuses to start the sandbox instead of silently degrading.", + "sandbox startup fails and `nemo-deepagents onboard` returns a nonzero exit", ); expect(deepAgents).not.toContain("### Landlock filesystem restrictions silently degraded"); expect(deepAgents).not.toContain("best_effort mode"); diff --git a/test/dcode-landlock-contract.test.ts b/test/dcode-landlock-contract.test.ts new file mode 100644 index 00000000000..a78ce09c49b --- /dev/null +++ b/test/dcode-landlock-contract.test.ts @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; + +const REPO_ROOT = path.resolve(import.meta.dirname, ".."); +const OPENSHELL_FIXTURE_PREFIX = "openshell-"; +const OPENSHELL_FIXTURE_SUFFIX = "-landlock-hard-requirement-missing-read-only-path.log"; + +function readRepoFile(...segments: string[]): string { + return readFileSync(path.join(REPO_ROOT, ...segments), "utf8"); +} + +describe("DCode Landlock release contract", () => { + it("keeps the schema, managed policy, and operator docs on hard_requirement (#5795)", () => { + const schema = JSON.parse(readRepoFile("schemas", "sandbox-policy.schema.json")) as { + properties: { landlock: { properties: { compatibility: { enum: string[] } } } }; + }; + const policy = YAML.parse( + readRepoFile("agents", "langchain-deepagents-code", "policy-additions.yaml"), + ) as { landlock?: { compatibility?: string } }; + + expect(schema.properties.landlock.properties.compatibility.enum).toEqual([ + "best_effort", + "hard_requirement", + ]); + expect(policy.landlock?.compatibility).toBe("hard_requirement"); + + for (const doc of [ + ["docs", "deployment", "sandbox-hardening.mdx"], + ["docs", "reference", "enterprise-readiness.mdx"], + ["docs", "reference", "troubleshooting.mdx"], + ["docs", "security", "best-practices.mdx"], + ]) { + expect(readRepoFile(...doc), doc.join("/")).toContain("hard_requirement"); + } + }); + + it("pins the classifier fixture to the exact supported OpenShell release (#5795)", () => { + const blueprint = YAML.parse(readRepoFile("nemoclaw-blueprint", "blueprint.yaml")) as { + min_openshell_version?: string; + max_openshell_version?: string; + }; + const version = blueprint.min_openshell_version; + + expect(version).toMatch(/^\d+\.\d+\.\d+$/); + expect(blueprint.max_openshell_version).toBe(version); + + const fixture = readRepoFile( + "test", + "fixtures", + `${OPENSHELL_FIXTURE_PREFIX}${version}${OPENSHELL_FIXTURE_SUFFIX}`, + ); + expect(fixture).toBe( + "Created sandbox: dcode-landlock-contract\n" + + "Error: Failed to prepare sandbox: Landlock path unavailable in hard_requirement mode: " + + "/definitely-missing-nemoclaw-landlock-contract (read_only): failed to open " + + '"/definitely-missing-nemoclaw-landlock-contract": No such file or directory (os error 2)\n', + ); + }); +}); diff --git a/test/e2e/fixtures/phases/onboarding.ts b/test/e2e/fixtures/phases/onboarding.ts index 180a0a46019..c10a05d9bf9 100644 --- a/test/e2e/fixtures/phases/onboarding.ts +++ b/test/e2e/fixtures/phases/onboarding.ts @@ -1,12 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import type { ArtifactSink } from "../artifacts.ts"; import { buildAvailabilityProbeEnv } from "../availability-env.ts"; -import { artifactLabel, assertExitZero, resultText } from "../clients/command.ts"; +import { + artifactLabel, + assertExitZero, + outputContainsSandbox, + resultText, +} from "../clients/command.ts"; import type { HostCliClient } from "../clients/host.ts"; import { validateSandboxName } from "../clients/sandbox.ts"; import { @@ -15,6 +20,7 @@ import { HOSTED_INFERENCE_CREDENTIAL_ENV, HOSTED_INFERENCE_PROVIDER, } from "../hosted-inference.ts"; +import { REPO_ROOT } from "../paths.ts"; import { redactString } from "../redaction.ts"; import type { ShellProbeResult } from "../shell-probe.ts"; import type { EnvironmentReady } from "./environment.ts"; @@ -53,6 +59,14 @@ const JAVASCRIPT_STACK_TRACE_PATTERNS = [ /(^|\s)(TypeError|ReferenceError|SyntaxError):/m, /^\s+at /m, ]; +const DCODE_POLICY_PATH = join( + REPO_ROOT, + "agents", + "langchain-deepagents-code", + "policy-additions.yaml", +); +const DCODE_LANDLOCK_MISSING_PATH = "/definitely-missing-nemoclaw-landlock-e2e"; +const DCODE_LANDLOCK_NEGATIVE_SANDBOX = "e2e-dcode-landlock-negative"; function hasJavaScriptStackTrace(text: string): boolean { return JAVASCRIPT_STACK_TRACE_PATTERNS.some((pattern) => pattern.test(text)); @@ -70,6 +84,8 @@ export interface OnboardingCleanup { export interface OnboardingOptions { sandboxName?: string; timeoutMs?: number; + /** Test-fixture override; live targets use the canonical DCode policy. */ + dcodePolicyPath?: string; } export type OnboardingExpectedFailure = @@ -241,6 +257,11 @@ export class OnboardingPhaseFixture { const sandboxName = sandboxNameFromOptions(environment.onboarding, options); const apiKey = this.secrets.required("NVIDIA_INFERENCE_API_KEY"); this.registerSandboxCleanup(sandboxName); + await this.proveDcodeLandlockFailureCleanup( + apiKey, + options.dcodePolicyPath ?? DCODE_POLICY_PATH, + options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ); const result = await this.host.nemoclaw([...ONBOARD_ARGS, "--observability"], { artifactName: "onboard-cloud-langchain-deepagents-code", env: commandEnv(sandboxName, { @@ -276,6 +297,83 @@ export class OnboardingPhaseFixture { }; } + private async proveDcodeLandlockFailureCleanup( + apiKey: string, + policyPath: string, + timeoutMs: number, + ): Promise { + const negativeSandboxName = DCODE_LANDLOCK_NEGATIVE_SANDBOX; + validateSandboxName(negativeSandboxName); + this.registerSandboxCleanup(negativeSandboxName); + + const originalPolicy = await readFile(policyPath, "utf8"); + const readOnlyHeader = /^ read_only:\s*$/gm; + const readOnlyHeaders = originalPolicy.match(readOnlyHeader) ?? []; + if (readOnlyHeaders.length !== 1) { + throw new Error( + `DCode Landlock negative proof expected one filesystem read_only block, found ${readOnlyHeaders.length}.`, + ); + } + const negativePolicy = originalPolicy.replace( + readOnlyHeader, + ` read_only:\n - ${DCODE_LANDLOCK_MISSING_PATH}`, + ); + + try { + await writeFile(policyPath, negativePolicy, "utf8"); + const negative = await this.host.nemoclaw(ONBOARD_ARGS, { + artifactName: "onboard-cloud-langchain-deepagents-code-landlock-negative", + env: commandEnv(negativeSandboxName, { + NEMOCLAW_AGENT: "langchain-deepagents-code", + NVIDIA_INFERENCE_API_KEY: apiKey, + [HOSTED_INFERENCE_CREDENTIAL_ENV]: apiKey, + }), + redactionValues: [apiKey], + timeoutMs, + }); + const negativeOutput = this.redact(resultText(negative), [apiKey]); + if (negative.exitCode === 0) { + throw new Error( + "DCode hard-required Landlock missing-path onboarding unexpectedly passed.", + ); + } + if (!negativeOutput.includes("Landlock path unavailable in hard_requirement mode")) { + throw new Error( + `DCode Landlock negative proof missed the hard-required path failure: ${negativeOutput}`, + ); + } + const openshellList = await this.host.command( + this.host.openshellCommandPath, + ["sandbox", "list"], + { + artifactName: "onboard-cloud-langchain-deepagents-code-landlock-openshell-list", + env: commandEnv(negativeSandboxName, { OPENSHELL_GATEWAY: "nemoclaw" }), + timeoutMs: 60_000, + }, + ); + assertExitZero(openshellList, "list OpenShell sandboxes after Landlock create failure"); + if (outputContainsSandbox(openshellList, negativeSandboxName)) { + throw new Error( + `OpenShell retained failed Landlock sandbox '${negativeSandboxName}' after cleanup.`, + ); + } + + const nemoclawList = await this.host.nemoclaw(["list"], { + artifactName: "onboard-cloud-langchain-deepagents-code-landlock-nemoclaw-list", + env: commandEnv(negativeSandboxName), + timeoutMs: 60_000, + }); + assertExitZero(nemoclawList, "list NemoClaw sandboxes after Landlock create failure"); + if (outputContainsSandbox(nemoclawList, negativeSandboxName)) { + throw new Error( + `NemoClaw recorded failed Landlock sandbox '${negativeSandboxName}' as managed state.`, + ); + } + } finally { + await writeFile(policyPath, originalPolicy, "utf8"); + } + } + async cloudOpenClawNoDocker( environment: EnvironmentReady, options: OnboardingOptions = {}, diff --git a/test/e2e/registry/definitions/baseline.ts b/test/e2e/registry/definitions/baseline.ts index b9a1647f91d..81d4c07dd08 100644 --- a/test/e2e/registry/definitions/baseline.ts +++ b/test/e2e/registry/definitions/baseline.ts @@ -87,7 +87,8 @@ const canonicalTargetInputs: CanonicalTargetInput[] = [ ), expectedStateId: "cloud-deepagents-code-ready", suiteIds: ["smoke", "inference", "terminal-agent", "deepagents-code-policy"], - description: "Ubuntu repo checkout with Docker and LangChain Deep Agents Code onboarding.", + description: + "Ubuntu repo checkout with Docker, hard-required Landlock failure cleanup, and successful LangChain Deep Agents Code onboarding.", requiredSecrets: ["NVIDIA_INFERENCE_API_KEY"], }, { diff --git a/test/e2e/support/e2e-phase-onboarding.test.ts b/test/e2e/support/e2e-phase-onboarding.test.ts index 8c245a6406b..b7ebdb656ed 100644 --- a/test/e2e/support/e2e-phase-onboarding.test.ts +++ b/test/e2e/support/e2e-phase-onboarding.test.ts @@ -154,21 +154,63 @@ describe("onboarding phase fixture", () => { ]); }); - it("opts the canonical Deep Agents Code target into composed observability", async () => { + it("proves hard-required Landlock cleanup before canonical Deep Agents Code readiness", async () => { const runner = new FakeRunner(); + runner.enqueue( + shellResult( + 1, + [ + "Landlock path unavailable in hard_requirement mode: /definitely-missing-nemoclaw-landlock-e2e", + ].join("\n"), + ), + ); + runner.enqueue(shellResult(0, "NAME STATUS\n")); + runner.enqueue(shellResult(0, "No sandboxes registered.\n")); runner.enqueue(shellResult(0, "onboarded\n")); const secrets = new FakeSecrets({ NVIDIA_INFERENCE_API_KEY: "secret-token" }); const onboard = new OnboardingPhaseFixture(new HostCliClient(runner), secrets); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-landlock-policy-")); + const policyPath = path.join(tempDir, "policy-additions.yaml"); + fs.writeFileSync( + policyPath, + "version: 1\nfilesystem_policy:\n read_only:\n - /usr\nnetwork_policies:\n fixture: {}\n", + "utf8", + ); const instance = await onboard.from(ready({ onboarding: "cloud-langchain-deepagents-code" }), { sandboxName: "e2e-ubuntu-repo-cloud-langchain-deepagents-code", + dcodePolicyPath: policyPath, }); expect(instance).toMatchObject({ agent: "langchain-deepagents-code", sandboxName: "e2e-ubuntu-repo-cloud-langchain-deepagents-code", }); + expect(fs.readFileSync(policyPath, "utf8")).toBe( + "version: 1\nfilesystem_policy:\n read_only:\n - /usr\nnetwork_policies:\n fixture: {}\n", + ); expect(runner.calls[0]).toMatchObject({ + command: "nemoclaw", + args: ["onboard", "--non-interactive", "--yes", "--yes-i-accept-third-party-software"], + options: { + artifactName: "onboard-cloud-langchain-deepagents-code-landlock-negative", + env: expect.objectContaining({ + NEMOCLAW_AGENT: "langchain-deepagents-code", + NEMOCLAW_SANDBOX_NAME: "e2e-dcode-landlock-negative", + }), + redactionValues: ["secret-token"], + timeoutMs: 900_000, + }, + }); + expect(runner.calls[1]).toMatchObject({ + command: "openshell", + args: ["sandbox", "list"], + }); + expect(runner.calls[2]).toMatchObject({ + command: "nemoclaw", + args: ["list"], + }); + expect(runner.calls[3]).toMatchObject({ command: "nemoclaw", args: [ "onboard", @@ -187,6 +229,32 @@ describe("onboarding phase fixture", () => { timeoutMs: 900_000, }, }); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("restores the exact DCode policy when the Landlock negative proof fails", async () => { + const runner = new FakeRunner(); + runner.enqueue(shellResult(1, "unexpected create failure\n")); + const onboard = new OnboardingPhaseFixture( + new HostCliClient(runner), + new FakeSecrets({ NVIDIA_INFERENCE_API_KEY: "secret-token" }), + ); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dcode-landlock-policy-restore-")); + const policyPath = path.join(tempDir, "policy-additions.yaml"); + const originalPolicy = + "version: 1\nfilesystem_policy:\n read_only:\n - /usr\nnetwork_policies:\n fixture: {}\n"; + fs.writeFileSync(policyPath, originalPolicy, "utf8"); + + await expect( + onboard.from(ready({ onboarding: "cloud-langchain-deepagents-code" }), { + sandboxName: "e2e-ubuntu-repo-cloud-langchain-deepagents-code", + dcodePolicyPath: policyPath, + }), + ).rejects.toThrow("missed the hard-required path failure"); + + expect(fs.readFileSync(policyPath, "utf8")).toBe(originalPolicy); + expect(runner.calls).toHaveLength(1); + fs.rmSync(tempDir, { recursive: true, force: true }); }); it("fails cloud OpenClaw onboarding on non-zero exit", async () => { diff --git a/test/fixtures/openshell-0.0.72-landlock-hard-requirement-missing-read-only-path.log b/test/fixtures/openshell-0.0.72-landlock-hard-requirement-missing-read-only-path.log new file mode 100644 index 00000000000..d0cccf97303 --- /dev/null +++ b/test/fixtures/openshell-0.0.72-landlock-hard-requirement-missing-read-only-path.log @@ -0,0 +1,2 @@ +Created sandbox: dcode-landlock-contract +Error: Failed to prepare sandbox: Landlock path unavailable in hard_requirement mode: /definitely-missing-nemoclaw-landlock-contract (read_only): failed to open "/definitely-missing-nemoclaw-landlock-contract": No such file or directory (os error 2) diff --git a/test/langchain-deepagents-code-image.test.ts b/test/langchain-deepagents-code-image.test.ts index add08030630..b81e58a838f 100644 --- a/test/langchain-deepagents-code-image.test.ts +++ b/test/langchain-deepagents-code-image.test.ts @@ -512,7 +512,7 @@ describe("LangChain Deep Agents Code image contracts", () => { expect(defaultPolicy.filesystem_policy?.read_only).toEqual( expect.arrayContaining(["/usr", "/opt/venv", "/etc"]), ); - expect(defaultPolicy.landlock).toMatchObject({ compatibility: "strict" }); + expect(defaultPolicy.landlock).toMatchObject({ compatibility: "hard_requirement" }); const githubBinaries = policyBinaryPaths(defaultPolicy, "github"); expect(githubBinaries).toEqual( diff --git a/test/onboard-landlock-failure.test.ts b/test/onboard-landlock-failure.test.ts new file mode 100644 index 00000000000..f73d21d23bb --- /dev/null +++ b/test/onboard-landlock-failure.test.ts @@ -0,0 +1,276 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +// This subprocess test exercises the full onboard FSM wiring with the real +// failure classifier, registry gate, and readiness tracing. It also protects +// the ownership boundary: unstructured create output cannot authorize deletion. +const MESSAGING_ENV_PREFIXES = ["DISCORD_", "SLACK_", "TELEGRAM_", "WHATSAPP_"] as const; + +type Scenario = { + name: string; + createStatus: number; + createOutput: string; + ready: boolean; +}; + +type Outcome = { + code: number; + commands: string[]; + registerCalls: unknown[]; + updateCalls: unknown[]; + sandboxName?: string; +}; + +function withoutMessagingCredentials(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return Object.fromEntries( + Object.entries(env).filter( + ([key]) => !MESSAGING_ENV_PREFIXES.some((prefix) => key.startsWith(prefix)), + ), + ); +} + +function runScenario(scenario: Scenario): { + result: ReturnType; + outcome: Outcome; +} { + const repoRoot = path.join(import.meta.dirname, ".."); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-landlock-flow-")); + const scriptPath = path.join(tempDir, "landlock-flow-check.cjs"); + const outputPath = path.join(tempDir, "outcome.json"); + + const modulePath = (relativePath: string): string => + JSON.stringify(path.join(repoRoot, relativePath)); + + const script = String.raw` +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const scenario = ${JSON.stringify(scenario)}; +const runner = require(${modulePath("src/lib/runner.ts")}); +const registry = require(${modulePath("src/lib/state/registry.ts")}); +const buildContextStage = require(${modulePath("src/lib/onboard/build-context-stage.ts")}); +const dockerfilePatchFlow = require(${modulePath("src/lib/onboard/sandbox-dockerfile-patch-flow.ts")}); +const sandboxCreateStream = require(${modulePath("src/lib/sandbox/create-stream.ts")}); +const readinessTracing = require(${modulePath("src/lib/onboard/sandbox-readiness-tracing.ts")}); +const failureDiagnostics = require(${modulePath("src/lib/onboard/sandbox-create-failure.ts")}); +const agentDefs = require(${modulePath("src/lib/agent/defs.ts")}); +const openshellResolve = require(${modulePath("src/lib/adapters/openshell/resolve.ts")}); + +const sandboxName = "dcode-landlock-flow"; +const commands = []; +const registerCalls = []; +const updateCalls = []; + +function writeOutcome(code, extra = {}) { + fs.writeFileSync( + ${JSON.stringify(outputPath)}, + JSON.stringify({ code, commands, registerCalls, updateCalls, ...extra }), + "utf8", + ); +} + +function commandText(command) { + return Array.isArray(command) ? command.join(" ") : String(command); +} + +runner.run = (command) => { + commands.push(commandText(command)); + return { status: 0, stdout: "", stderr: "" }; +}; +runner.runFile = (file, args = []) => { + commands.push(commandText([file, ...args])); + return { status: 0, stdout: "", stderr: "" }; +}; +runner.runOpenshell = (args) => { + commands.push(commandText(["openshell", ...args])); + return { status: 0, stdout: "", stderr: "" }; +}; +runner.runCapture = (command) => { + commands.push(commandText(command)); + const text = commandText(command); + if (text.endsWith("sandbox exec -n dcode-landlock-flow -- dcode identity")) { + return [ + "Route: inference", + "Provider: nvidia-prod", + "Model: openai:nvidia/nemotron-3-super-120b-a12b", + "Endpoint: https://inference.local/v1", + ].join("\n"); + } + if (text.includes("openshell sandbox get") || text.includes("openshell sandbox list")) { + return ""; + } + return "5.15.0"; +}; +runner.runCaptureOpenshell = (args) => { + commands.push(commandText(["openshell", ...args])); + return ""; +}; +openshellResolve.resolveOpenshell = () => "/usr/bin/openshell"; + +registry.getSandbox = () => null; +registry.listExtraProviders = () => []; +registry.registerSandbox = (entry) => { + registerCalls.push(entry); + return true; +}; +registry.updateSandbox = (name, updates) => { + updateCalls.push({ name, updates }); + return true; +}; +registry.removeSandbox = () => true; +registry.getDefault = () => null; +registry.setDefault = () => true; + +buildContextStage.stageCreateSandboxBuildContext = () => { + const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-landlock-build-")); + const stagedDockerfile = path.join(buildCtx, "Dockerfile"); + fs.writeFileSync(stagedDockerfile, "FROM scratch\n", "utf8"); + return { + buildCtx, + stagedDockerfile, + cleanupBuildCtx: () => { + fs.rmSync(buildCtx, { recursive: true, force: true }); + return true; + }, + }; +}; + +dockerfilePatchFlow.prepareSandboxDockerfilePatch = async () => ({ + buildId: "landlock-flow-test", + resolvedBaseImage: null, +}); + +sandboxCreateStream.streamSandboxCreate = async (command) => { + commands.push(command); + return { + status: scenario.createStatus, + output: scenario.createOutput, + sawProgress: true, + }; +}; + +readinessTracing.waitForCreatedSandboxReadyWithTrace = () => ({ + ready: scenario.ready, + reason: scenario.ready ? "ready" : "terminal_failure_phase", + failurePhase: scenario.ready ? null : "Failed", +}); +readinessTracing.printReadinessFailure = () => undefined; +failureDiagnostics.collectSandboxCreateFailureDiagnostics = () => null; + +const originalExit = process.exit; +process.exit = (code) => { + writeOutcome(code); + originalExit(code); +}; + +const { createSandbox } = require(${modulePath("src/lib/onboard.ts")}); +const agent = agentDefs.loadAgent("langchain-deepagents-code"); + +createSandbox( + null, + "nvidia/nemotron-3-super-120b-a12b", + "nvidia-prod", + null, + sandboxName, + null, + [], + null, + agent, +).then((name) => { + writeOutcome(0, { sandboxName: name }); +}).catch((error) => { + console.error(error); + process.exit(1); +}); +`; + + fs.writeFileSync(scriptPath, script, "utf8"); + + const env = withoutMessagingCredentials(process.env); + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf8", + env: { + ...env, + HOME: tempDir, + NEMOCLAW_HOME: path.join(tempDir, ".nemoclaw"), + NEMOCLAW_DOCKER_GPU_PATCH: "0", + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_POLICY_TIER: "restricted", + NEMOCLAW_SANDBOX_GPU: "0", + OPENSHELL_GATEWAY: "nemoclaw", + }, + timeout: 20_000, + }); + + expect(fs.existsSync(outputPath), result.stderr).toBe(true); + return { + result, + outcome: JSON.parse(fs.readFileSync(outputPath, "utf8")) as Outcome, + }; +} + +describe("DCode Landlock onboarding flow", () => { + it.each([ + { + name: "kernel unsupported", + createStatus: 1, + createOutput: + "Created sandbox: dcode-landlock-flow\n" + + "Landlock unavailable in hard_requirement mode: kernel does not support Landlock", + ready: false, + }, + { + name: "policy path unavailable", + createStatus: 1, + createOutput: + "Created sandbox: dcode-landlock-flow\n" + + "Landlock path unavailable in hard_requirement mode: /app (read_only): No such file or directory", + ready: false, + }, + ])("fails closed without guessing at sandbox ownership when $name (#5795)", (scenario) => { + const { result, outcome } = runScenario(scenario); + const stderr = String(result.stderr); + + expect(result.status, stderr).toBe(1); + expect(outcome.code).toBe(1); + expect(stderr).toContain(scenario.createOutput.split("\n").at(-1)); + expect(stderr).toContain("could not apply required Landlock filesystem isolation"); + expect( + outcome.commands.some((command) => + command.endsWith("openshell sandbox delete dcode-landlock-flow"), + ), + ).toBe(false); + expect(outcome.registerCalls).toEqual([]); + expect(outcome.updateCalls).toEqual([]); + }); + + it("registers a hard-required DCode sandbox after OpenShell reports Ready", () => { + const { result, outcome } = runScenario({ + name: "success", + createStatus: 0, + createOutput: "Created sandbox: dcode-landlock-flow", + ready: true, + }); + const stderr = String(result.stderr); + + expect(result.status, stderr).toBe(0); + expect(outcome.code).toBe(0); + expect(outcome.sandboxName).toBe("dcode-landlock-flow"); + expect(outcome.registerCalls).toHaveLength(1); + expect(outcome.updateCalls).toEqual([]); + expect( + outcome.commands.some((command) => + command.endsWith("openshell sandbox delete dcode-landlock-flow"), + ), + ).toBe(false); + }); +}); diff --git a/test/onboard-sandbox-create-failure.test.ts b/test/onboard-sandbox-create-failure.test.ts index 364d978d606..4e34dbb6ba4 100644 --- a/test/onboard-sandbox-create-failure.test.ts +++ b/test/onboard-sandbox-create-failure.test.ts @@ -12,6 +12,10 @@ import { printSandboxCreateFailureDiagnostics, } from "../src/lib/onboard/sandbox-create-failure.js"; +function permissionMode(filePath: string): number { + return fs.statSync(filePath).mode & 0o777; +} + describe("sandbox create failure diagnostics", () => { it("preserves gateway failure lines and VM console output before cleanup", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-create-failure-")); @@ -23,7 +27,15 @@ describe("sandbox create failure diagnostics", () => { const gatewayLogPath = path.join(logDir, "openshell-gateway.log"); fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(consolePath, "vm console detail\n"); + const rawSecrets = ["secret-token", "nvapi-secret", "session-secret"]; + fs.writeFileSync( + consolePath, + [ + "vm console detail", + "Authorization: Bearer secret-token", + "NVIDIA_API_KEY=nvapi-secret", + ].join("\n"), + ); fs.writeFileSync( gatewayLogPath, [ @@ -31,6 +43,8 @@ describe("sandbox create failure diagnostics", () => { `2026-05-12T20:30:56Z INFO vm driver: create_sandbox received sandbox_id=${sandboxId} sandbox_name=my-assistant`, `2026-05-12T20:30:56Z INFO vm driver: resolved image ref, preparing rootfs sandbox_id=${sandboxId} state_dir=${stateDir}`, `2026-05-12T20:34:28Z INFO vm driver: spawning VM launcher sandbox_id=${sandboxId} console_output=${consolePath}`, + `2026-05-12T20:34:28Z ERROR supervisor sandbox_id=${sandboxId} Authorization: Bearer secret-token`, + `2026-05-12T20:34:28Z ERROR supervisor sandbox_id=${sandboxId} Cookie: session=session-secret`, "[2026-05-12T20:34:29Z ERROR krun] Building the microVM failed: Internal(Vm(VmSetup(VmCreate)))", `2026-05-12T20:34:29Z WARN Sandbox failed to become ready sandbox_id=${sandboxId} sandbox_name=my-assistant reason=ProcessExited`, ].join("\n"), @@ -46,18 +60,29 @@ describe("sandbox create failure diagnostics", () => { expect(diagnostics?.copiedConsoleOutput).toBe( path.join(diagnostics!.dir, "rootfs-console.log"), ); - expect(fs.readFileSync(path.join(diagnostics!.dir, "rootfs-console.log"), "utf-8")).toContain( - "vm console detail", - ); - const relevant = fs.readFileSync( - path.join(diagnostics!.dir, "openshell-gateway-relevant.log"), - "utf-8", - ); + const copiedConsolePath = path.join(diagnostics!.dir, "rootfs-console.log"); + const relevantPath = path.join(diagnostics!.dir, "openshell-gateway-relevant.log"); + const summaryPath = path.join(diagnostics!.dir, "summary.txt"); + const copiedConsole = fs.readFileSync(copiedConsolePath, "utf-8"); + expect(copiedConsole).toContain("vm console detail"); + expect(copiedConsole).toContain("Bearer "); + expect(copiedConsole).not.toContain("secret-token"); + const relevant = fs.readFileSync(relevantPath, "utf-8"); expect(relevant).toContain("VmCreate"); expect(relevant).toContain("sandbox_name=my-assistant"); - expect(fs.readFileSync(path.join(diagnostics!.dir, "summary.txt"), "utf-8")).toContain( - "backup_path=/tmp/pre-upgrade-backup", - ); + expect(relevant).toContain("Bearer "); + const summary = fs.readFileSync(summaryPath, "utf-8"); + expect(summary).toContain("backup_path=/tmp/pre-upgrade-backup"); + for (const secret of rawSecrets) { + expect(copiedConsole).not.toContain(secret); + expect(relevant).not.toContain(secret); + expect(summary).not.toContain(secret); + expect(diagnostics?.summaryLines.join("\n")).not.toContain(secret); + } + expect(permissionMode(diagnostics!.dir)).toBe(0o700); + for (const artifactPath of [copiedConsolePath, relevantPath, summaryPath]) { + expect(permissionMode(artifactPath), artifactPath).toBe(0o600); + } }); it("prints saved diagnostics and retained backup details", () => { @@ -94,7 +119,7 @@ describe("sandbox create failure diagnostics", () => { gatewayLogPath, [ "2026-05-12T20:30:00Z INFO gateway starting", - "2026-05-12T20:30:01Z WARN gateway exited before request dispatch", + "2026-05-12T20:30:01Z WARN gateway exited before request dispatch Authorization: Bearer secret-token", ].join("\n"), ); @@ -106,12 +131,17 @@ describe("sandbox create failure diagnostics", () => { expect(diagnostics?.gatewayTailPath).toBe( path.join(diagnostics!.dir, "openshell-gateway-tail.log"), ); - expect(fs.readFileSync(diagnostics!.gatewayTailPath!, "utf-8")).toContain( - "gateway exited before request dispatch", - ); - expect(diagnostics?.summaryLines).toContain( - "2026-05-12T20:30:01Z WARN gateway exited before request dispatch", - ); + const gatewayTail = fs.readFileSync(diagnostics!.gatewayTailPath!, "utf-8"); + expect(gatewayTail).toContain("gateway exited before request dispatch"); + expect(gatewayTail).toContain("Bearer "); + expect(gatewayTail).not.toContain("secret-token"); + expect( + diagnostics?.summaryLines.some((line) => + line.includes("gateway exited before request dispatch"), + ), + ).toBe(true); + expect(diagnostics?.summaryLines.join("\n")).not.toContain("secret-token"); + expect(permissionMode(diagnostics!.gatewayTailPath!)).toBe(0o600); expect(fs.readFileSync(path.join(diagnostics!.dir, "summary.txt"), "utf-8")).toContain( "gateway_tail=", ); diff --git a/test/validate-config-schemas.test.ts b/test/validate-config-schemas.test.ts index 4fb75274f91..3dc1fb0fdb2 100644 --- a/test/validate-config-schemas.test.ts +++ b/test/validate-config-schemas.test.ts @@ -246,6 +246,7 @@ describe("config validation target discovery", () => { expect.arrayContaining([ "nemoclaw-blueprint/policies/openclaw-sandbox.yaml", "nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml", + "agents/langchain-deepagents-code/policy-additions.yaml", "agents/hermes/policy-additions.yaml", "agents/hermes/policy-permissive.yaml", "agents/openclaw/policy-permissive.yaml", @@ -468,6 +469,19 @@ describe("sandbox-policy.schema.json", () => { expect(validate(bad)).toBe(false); }); + it("accepts fail-closed Landlock compatibility for DCode (#5795)", () => { + const valid = { + ...cloneObject(validSandboxPolicy), + landlock: { compatibility: "hard_requirement" }, + }; + expectValid(validate, valid, "hard-required Landlock policy"); + }); + + it("rejects the legacy strict Landlock compatibility value (#5795)", () => { + const legacy = { ...cloneObject(validSandboxPolicy), landlock: { compatibility: "strict" } }; + expect(validate(legacy)).toBe(false); + }); + it("rejects sandbox-policy endpoint with protocol rest but no rules", () => { const bad = { version: 1,