diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 015e18bb5e8..757603bd29f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -168,6 +168,14 @@ repos: pass_filenames: true priority: 10 + - id: oxlint-adapters-type-aware + name: Oxlint adapter type-aware rules + entry: npx oxlint --config oxlint.type-aware.config.ts --tsconfig tsconfig.cli.json --no-error-on-unmatched-pattern + language: system + files: ^src/lib/adapters/.*\.ts$ + pass_filenames: true + priority: 10 + - id: validate-config-schemas name: Validate config files against JSON schemas entry: npx tsx scripts/validate-configs.mts diff --git a/AGENTS.md b/AGENTS.md index 8e614171d43..9e2d1bf3aef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,7 +140,11 @@ Every source file needs the repository SPDX header; the pre-commit hook inserts - `bin/` launcher and remaining `scripts/*.js`: **CommonJS** (`require`/`module.exports`), Node.js 22.19+ - `test/`: **ESM** (`import`/`export`) - Do not add new JavaScript source files. Prefer TypeScript when modifying existing JavaScript. New test files must use TypeScript. -- Oxlint uses `oxlint.config.ts`. The isolated `oxlint.type-aware.config.ts` configuration enforces `typescript/no-floating-promises` for plugin sources. +- Oxlint uses `oxlint.config.ts`. The isolated `oxlint.type-aware.config.ts` configuration checks promises in plugin and adapter sources. + Adapter checks use `--tsconfig tsconfig.cli.json` and also reject misused promises, invalid awaits, and incomplete switches. +- Adapter sources and tests require type-only imports and exports, strict equality, and no unused variables or explicit `any`. + Production adapters also reject non-null assertions and nested ternaries. +- Oxfmt covers all `src/lib/adapters` files. The formatting hook also formats changes to existing adapters. - Use `eslint-plugin-sonarjs` only for the `oxlint.config.ts` cognitive-complexity rules documented in [`tools/lint/DEPENDENCY-REVIEW.md`](tools/lint/DEPENDENCY-REVIEW.md). - Keep function complexity low; existing complexity hotspots are tracked separately diff --git a/oxlint.config.ts b/oxlint.config.ts index 55d6df83a11..0ebd7c76e3d 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -98,6 +98,32 @@ export default defineConfig({ "no-nested-ternary": "error", }, }, + { + files: ["src/lib/adapters/**/*.ts"], + rules: { + "no-unused-vars": "error", + "no-debugger": "error", + eqeqeq: "error", + "typescript/no-explicit-any": "error", + "typescript/consistent-type-exports": "error", + "typescript/consistent-type-imports": [ + "error", + { + disallowTypeAnnotations: false, + fixStyle: "separate-type-imports", + prefer: "type-imports", + }, + ], + }, + }, + { + files: ["src/lib/adapters/**/*.ts"], + excludeFiles: ["**/*.test.ts"], + rules: { + "no-nested-ternary": "error", + "typescript/no-non-null-assertion": "error", + }, + }, // Pin the migration-baseline SonarJS scores for existing hotspots so later changes cannot increase them. { diff --git a/oxlint.type-aware.config.ts b/oxlint.type-aware.config.ts index 655dcbd09ea..591a4df25a8 100644 --- a/oxlint.type-aware.config.ts +++ b/oxlint.type-aware.config.ts @@ -20,6 +20,17 @@ export default defineConfig({ typeAware: true, }, plugins: ["typescript"], + overrides: [ + { + files: ["src/lib/adapters/**/*.ts"], + rules: { + "typescript/no-misused-promises": "error", + "typescript/consistent-type-exports": "error", + "typescript/await-thenable": "error", + "typescript/switch-exhaustiveness-check": "error", + }, + }, + ], rules: { "typescript/no-floating-promises": "error", }, diff --git a/package.json b/package.json index 6296e476b8d..d03484ab210 100644 --- a/package.json +++ b/package.json @@ -63,8 +63,8 @@ "check:diff": "npm run validate:pr", "checks:repository": "tsx scripts/checks/run.mts", "checks": "node -e \"console.error('npm run checks runs only narrow repository checks. Use npm run validate:pr for routine PR validation or npm run checks:repository for the narrow runner.')\" && npm run checks:repository", - "lint": "oxlint . .dsh/tools && oxlint --config oxlint.type-aware.config.ts nemoclaw/src && npm run format:check && npm run checks:repository", - "lint:fix": "oxlint --fix . .dsh/tools && oxlint --config oxlint.type-aware.config.ts nemoclaw/src && npm run format && npm run checks:repository", + "lint": "oxlint . .dsh/tools && oxlint --config oxlint.type-aware.config.ts nemoclaw/src && oxlint --config oxlint.type-aware.config.ts --tsconfig tsconfig.cli.json src/lib/adapters && npm run format:check && npm run checks:repository", + "lint:fix": "oxlint --fix . .dsh/tools && oxlint --config oxlint.type-aware.config.ts nemoclaw/src && oxlint --config oxlint.type-aware.config.ts --tsconfig tsconfig.cli.json src/lib/adapters && npm run format && npm run checks:repository", "lint:ts": "cd nemoclaw && npm run check", "format": "bash tools/lint/format-added-files.sh --write", "format:check": "bash tools/lint/format-added-files.sh --check", diff --git a/src/lib/adapters/config/live-export-source.test.ts b/src/lib/adapters/config/live-export-source.test.ts index 93d879750a4..07f0578ee23 100644 --- a/src/lib/adapters/config/live-export-source.test.ts +++ b/src/lib/adapters/config/live-export-source.test.ts @@ -17,9 +17,14 @@ vi.mock("../openshell/sdk", () => ({ connectManagedOpenShellSdk: vi.fn() })); vi.mock("../openshell/sanitized-capture", () => ({ captureSanitizedResolvedOpenshell: vi.fn(), })); -vi.mock("../openshell/sandbox-policy-cli", () => ({ - syncCliOpenShellSandboxPolicyReader: { readSandboxPolicy: vi.fn() }, -})); +vi.mock("../openshell/sandbox-config", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createSandboxConfig: () => + actual.createSandboxConfig(undefined, async (policy) => YAML.stringify(policy)), + }; +}); vi.mock("../../onboard/gateway/state-dir", () => ({ managedGatewayStateRootOwnershipFailure: vi.fn(() => null), resolveGatewayStateDirForPort: vi.fn(() => "/managed/gateway"), @@ -35,7 +40,6 @@ import { connectManagedOpenShellSdk } from "../openshell/sdk"; import { observeStableExportSource } from "../../actions/config/observe-export-source"; import { captureSanitizedResolvedOpenshell } from "../openshell/sanitized-capture"; import { fingerprintOpenShellSandboxId } from "../openshell/sandbox-identity"; -import { syncCliOpenShellSandboxPolicyReader } from "../openshell/sandbox-policy-cli"; import { createLiveExportSnapshotReader } from "./live-export-source"; const sandboxId = "123e4567-e89b-42d3-a456-426614174000"; @@ -142,6 +146,18 @@ function provider() { } function configuration(revision = 3) { return { + policy: { + version: 1, + process: { run_as_user: "sandbox", run_as_group: "sandbox" }, + filesystem_policy: { include_workdir: false, read_only: ["/usr"], read_write: ["/sandbox"] }, + network_policies: { + api: { + name: "api", + endpoints: [{ host: "api.example.com", port: 443 }], + binaries: [{ path: "/usr/bin/curl" }], + }, + }, + }, workspace: "default", version: revision, policyHash: "a".repeat(64), @@ -175,15 +191,7 @@ function mockSupportedLiveSource( vi.mocked(connectManagedOpenShellSdk).mockResolvedValue({ raw }); raw.getProvider.mockResolvedValue(provider()); raw.getSandbox.mockResolvedValue(inventory(7, policyVersion)); - raw.getSandboxConfig.mockResolvedValue(configuration(policyVersion)); - vi.mocked(syncCliOpenShellSandboxPolicyReader.readSandboxPolicy).mockReturnValue({ - ok: true, - value: { - document: - "version: 1\nprocess:\n run_as_user: sandbox\n run_as_group: sandbox\nnetwork_policies:\n api:\n name: api\n endpoints: [{host: api.example.com, port: 443}]\n binaries: [{path: /usr/bin/curl}]\nfilesystem_policy:\n include_workdir: false\n read_only: [/usr]\n read_write: [/sandbox]\n", - appliedRevision, - }, - }); + raw.getSandboxConfig.mockResolvedValue(configuration(appliedRevision)); } function nativeNvidiaProvider() { @@ -255,11 +263,9 @@ describe("live export snapshot reader", () => { { stage: "effective-policy", fail: () => - vi - .mocked(syncCliOpenShellSandboxPolicyReader.readSandboxPolicy) - .mockImplementationOnce(() => { - throw new Error(readFailureCanary); - }), + raw.getSandboxConfig.mockImplementationOnce(() => { + throw new Error(readFailureCanary); + }), }, ] as const)("tags a sanitized $stage read failure", async ({ stage, fail }) => { mockSupportedLiveSource(); @@ -325,10 +331,6 @@ describe("live export snapshot reader", () => { const first = await reader.read("alpha"); raw.getSandbox.mockResolvedValue(inventory(8, 4)); raw.getSandboxConfig.mockResolvedValue(configuration(4)); - vi.mocked(syncCliOpenShellSandboxPolicyReader.readSandboxPolicy).mockReturnValue({ - ok: true, - value: { document: "version: 1\nnetwork_policies: {}\n", appliedRevision: 4 }, - }); const second = await reader.read("alpha"); @@ -347,12 +349,9 @@ describe("live export snapshot reader", () => { it("sanitizes credential-bearing policy failures", async () => { mockSupportedLiveSource(); const canary = "credential-canary-value"; - vi.mocked(syncCliOpenShellSandboxPolicyReader.readSandboxPolicy).mockReturnValue({ - ok: true, - value: { - document: `version: 1\nenv: {TOKEN: "${canary}"}\nnetwork_policies: {}\n`, - appliedRevision: 3, - }, + raw.getSandboxConfig.mockResolvedValue({ + ...configuration(), + policy: { ...configuration().policy, env: { TOKEN: canary } }, }); const result = await createLiveExportSnapshotReader().read("alpha"); @@ -376,6 +375,37 @@ describe("live export snapshot reader", () => { }); }); + it.each([0, 3])( + "preserves global policy revision agreement for revision %i", + async (globalPolicyVersion) => { + mockSupportedLiveSource(); + raw.getSandboxConfig.mockResolvedValue({ + ...configuration(), + policySource: 2, + globalPolicyVersion, + }); + const result = await createLiveExportSnapshotReader().read("alpha"); + expect(result).toMatchObject({ + kind: "observed", + policy: { revision: "3" }, + configuration: { policySource: "global", globalPolicyVersion }, + }); + }, + ); + + it("rejects a global policy revision that differs from the sandbox revision", async () => { + mockSupportedLiveSource(); + raw.getSandboxConfig.mockResolvedValue({ + ...configuration(), + policySource: 2, + globalPolicyVersion: 4, + }); + await expect(createLiveExportSnapshotReader().read("alpha")).resolves.toEqual({ + kind: "read-failed", + stage: "effective-policy", + }); + }); + it("maps route and policy revision drift to controlled read failures", async () => { mockSupportedLiveSource(4, 3); await expect(createLiveExportSnapshotReader().read("alpha")).resolves.toEqual({ @@ -515,6 +545,7 @@ describe("live export snapshot reader", () => { }, }); expect(raw.getProvider).toHaveBeenCalledTimes(2); + expect(raw.getSandboxConfig).toHaveBeenCalledTimes(2); expect(JSON.stringify(result)).not.toContain(readFailureCanary); }); diff --git a/src/lib/adapters/config/live-export-source.ts b/src/lib/adapters/config/live-export-source.ts index 799f6b4373b..5f802357077 100644 --- a/src/lib/adapters/config/live-export-source.ts +++ b/src/lib/adapters/config/live-export-source.ts @@ -11,7 +11,6 @@ import { createSandboxConfig } from "../openshell/sandbox-config"; import { captureSanitizedResolvedOpenshell } from "../openshell/sanitized-capture"; import { fingerprintOpenShellSandboxId } from "../openshell/sandbox-identity"; import { namedOpenShellGateway } from "../openshell/sandbox-observer"; -import { syncCliOpenShellSandboxPolicyReader } from "../openshell/sandbox-policy-cli"; import { EXPORT_REGISTRY_EVIDENCE_KEYS } from "../../domain/config/export-evidence"; import type { ExportSnapshotReadStage, @@ -193,39 +192,29 @@ async function inferenceFor( }; } -async function effectivePolicy( - sandboxName: string, - gateway: ObservedExportGateway, - row: Sandbox, - signal: AbortSignal, -) { - const configuration = await createSandboxConfig().get({ +async function effectivePolicy(gateway: ObservedExportGateway, row: Sandbox, signal: AbortSignal) { + const { policy, ...configuration } = await createSandboxConfig().get({ target: namedOpenShellGateway(gateway.name), workspace: row.workspace, sandboxId: row.id, signal, }); - const result = syncCliOpenShellSandboxPolicyReader.readSandboxPolicy({ - target: namedOpenShellGateway(gateway.name), - sandboxName, - scope: "effective", - }); - if (!result.ok || result.value.appliedRevision === null) { + if (policy.appliedRevision === null) { throw new Error("The effective OpenShell policy and its applied revision could not be read."); } - if (!isSandboxPolicyCredentialFree(result.value.document)) { + if (!isSandboxPolicyCredentialFree(policy.document)) { throw new Error("The effective OpenShell policy is not credential-free."); } if ( - row.policyVersion !== result.value.appliedRevision || - configuration.revision !== result.value.appliedRevision + row.policyVersion !== policy.appliedRevision || + configuration.revision !== policy.appliedRevision ) { throw new Error("The effective OpenShell policy revision does not match the live sandbox."); } return { sandboxId: row.id, - revision: String(result.value.appliedRevision), - document: result.value.document, + revision: String(policy.appliedRevision), + document: policy.document, configuration, }; } @@ -259,7 +248,7 @@ async function readSnapshot(sandboxName: string): Promise { signal, ); stage = "effective-policy"; - const { configuration, ...policy } = await effectivePolicy(sandboxName, gateway, row, signal); + const { configuration, ...policy } = await effectivePolicy(gateway, row, signal); return { kind: "observed", sandboxName, diff --git a/src/lib/adapters/docker/image.ts b/src/lib/adapters/docker/image.ts index a979f8adde3..7630a8f1542 100644 --- a/src/lib/adapters/docker/image.ts +++ b/src/lib/adapters/docker/image.ts @@ -37,10 +37,16 @@ export function dockerBuild( ...(quiet ? ["--quiet"] : []), ...Object.entries(buildArgs ?? {}) - .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .sort(([left], [right]) => { + if (left < right) return -1; + return left > right ? 1 : 0; + }) .flatMap(([key, value]) => ["--build-arg", `${key}=${value}`]), ...Object.entries(labels ?? {}) - .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .sort(([left], [right]) => { + if (left < right) return -1; + return left > right ? 1 : 0; + }) .flatMap(([key, value]) => ["--label", `${key}=${value}`]), "-f", dockerfilePath, diff --git a/src/lib/adapters/http/auth-config.test.ts b/src/lib/adapters/http/auth-config.test.ts index 95d3c1ddcc0..c43c2cb0694 100644 --- a/src/lib/adapters/http/auth-config.test.ts +++ b/src/lib/adapters/http/auth-config.test.ts @@ -97,15 +97,14 @@ describe("curl auth config helper", () => { } }); - it.each([ - " : value", - "Bad Header: value", - "missing-colon", - ])("rejects invalid OpenAI-like provider header %j", (header) => { - expect(() => parseOpenAiLikeExtraHeaders([header])).toThrow( - "invalid OpenAI-like provider header", - ); - }); + it.each([" : value", "Bad Header: value", "missing-colon"])( + "rejects invalid OpenAI-like provider header %j", + (header) => { + expect(() => parseOpenAiLikeExtraHeaders([header])).toThrow( + "invalid OpenAI-like provider header", + ); + }, + ); it("accepts every HTTP token character in an OpenAI-like provider header name", () => { const tokenChars = diff --git a/src/lib/adapters/http/container-curl-probe.test.ts b/src/lib/adapters/http/container-curl-probe.test.ts index 11da9590efb..99f2d8c7741 100644 --- a/src/lib/adapters/http/container-curl-probe.test.ts +++ b/src/lib/adapters/http/container-curl-probe.test.ts @@ -95,13 +95,11 @@ describe("container curl probe", () => { it.each(["20x", "200 extra"])( "rejects a malformed HTTP status write-out without creating the response file: %s (#9116)", (httpStatus) => { - const spawn = vi.fn( - (_command: string, args: readonly string[]) => { - const writeOutIndex = args.indexOf("-w"); - const writeOut = args[writeOutIndex + 1]; - return successfulSpawn(`{}${writeOut.replace("%{http_code}", httpStatus)}`); - }, - ); + const spawn = vi.fn((_command: string, args: readonly string[]) => { + const writeOutIndex = args.indexOf("-w"); + const writeOut = args[writeOutIndex + 1]; + return successfulSpawn(`{}${writeOut.replace("%{http_code}", httpStatus)}`); + }); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-curl-probe-test-")); const outputPath = path.join(tempDir, "response.json"); @@ -144,13 +142,11 @@ describe("container curl probe", () => { }); it("does not follow a replacement output symlink (#9116)", () => { - const spawn = vi.fn( - (_command: string, args: readonly string[]) => { - const writeOutIndex = args.indexOf("-w"); - const writeOut = args[writeOutIndex + 1]; - return successfulSpawn(`replacement${writeOut.replace("%{http_code}", "200")}`); - }, - ); + const spawn = vi.fn((_command: string, args: readonly string[]) => { + const writeOutIndex = args.indexOf("-w"); + const writeOut = args[writeOutIndex + 1]; + return successfulSpawn(`replacement${writeOut.replace("%{http_code}", "200")}`); + }); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-curl-probe-test-")); const targetPath = path.join(tempDir, "target.json"); const outputPath = path.join(tempDir, "response.json"); diff --git a/src/lib/adapters/http/curl-args.test.ts b/src/lib/adapters/http/curl-args.test.ts index ad2990ea18e..fa947896257 100644 --- a/src/lib/adapters/http/curl-args.test.ts +++ b/src/lib/adapters/http/curl-args.test.ts @@ -217,13 +217,11 @@ describe("validateCurlProbeArgs — credential-leak defence", () => { ).not.toThrow(); }); - it.each( - [ - "llm.corp.example:443:10.0.0.8", - "llm.corp.example:443:93.184.216.34", - "llm.corp.example:443:10.0.0.8,93.184.216.34,8.8.8.8", - ], - )( + it.each([ + "llm.corp.example:443:10.0.0.8", + "llm.corp.example:443:93.184.216.34", + "llm.corp.example:443:10.0.0.8,93.184.216.34,8.8.8.8", + ])( "requires the exact mixed public and private pin set at the curl boundary [%s] (#8176)", async (mapping) => { const endpointUrl = "https://llm.corp.example/v1/models"; diff --git a/src/lib/adapters/http/curl-args.ts b/src/lib/adapters/http/curl-args.ts index dd9318078d6..a142970d979 100644 --- a/src/lib/adapters/http/curl-args.ts +++ b/src/lib/adapters/http/curl-args.ts @@ -211,11 +211,9 @@ function getTrustedPrivateResolveAddresses( } const targetHost = normalizeHostname(target.hostname); const authorityAddresses = - opts.pinnedAddresses.length > 0 - ? opts.pinnedAddresses - : isIP(targetHost) !== 0 - ? [targetHost] - : opts.pinnedAddresses; + opts.pinnedAddresses.length === 0 && isIP(targetHost) !== 0 + ? [targetHost] + : opts.pinnedAddresses; return assertTrustedPrivateEndpointCapability( target.hostname, authorityAddresses, diff --git a/src/lib/adapters/http/probe.test.ts b/src/lib/adapters/http/probe.test.ts index b3d68153f36..ed31adca690 100644 --- a/src/lib/adapters/http/probe.test.ts +++ b/src/lib/adapters/http/probe.test.ts @@ -552,43 +552,44 @@ describe("http-probe helpers", () => { expect(spawnedEnv?.MY_SECRET_TOKEN).toBeUndefined(); }); - it.each( - ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"], - )("bypasses ambient proxies when --resolve pins the validated origin [%s] (#6293)", (name) => { - let spawnedEnv: NodeJS.ProcessEnv | undefined; - runCurlProbe( - ["-sS", "--resolve", "example.test:443:93.184.216.34", "https://example.test/models"], - { - pinnedAddresses: ["93.184.216.34"], - replaceEnv: true, - env: { - PATH: "/usr/bin", - HTTP_PROXY: "http://proxy.internal:3128", - HTTPS_PROXY: "http://proxy.internal:3128", - ALL_PROXY: "socks5://proxy.internal:1080", - http_proxy: "http://proxy.internal:3128", - https_proxy: "http://proxy.internal:3128", - all_proxy: "socks5://proxy.internal:1080", - }, - spawnSyncImpl: (_command, _args, options) => { - spawnedEnv = options.env as NodeJS.ProcessEnv; - return { - pid: 1, - output: [], - stdout: "200", - stderr: "", - status: 0, - signal: null, - }; + it.each(["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"])( + "bypasses ambient proxies when --resolve pins the validated origin [%s] (#6293)", + (name) => { + let spawnedEnv: NodeJS.ProcessEnv | undefined; + runCurlProbe( + ["-sS", "--resolve", "example.test:443:93.184.216.34", "https://example.test/models"], + { + pinnedAddresses: ["93.184.216.34"], + replaceEnv: true, + env: { + PATH: "/usr/bin", + HTTP_PROXY: "http://proxy.internal:3128", + HTTPS_PROXY: "http://proxy.internal:3128", + ALL_PROXY: "socks5://proxy.internal:1080", + http_proxy: "http://proxy.internal:3128", + https_proxy: "http://proxy.internal:3128", + all_proxy: "socks5://proxy.internal:1080", + }, + spawnSyncImpl: (_command, _args, options) => { + spawnedEnv = options.env as NodeJS.ProcessEnv; + return { + pid: 1, + output: [], + stdout: "200", + stderr: "", + status: 0, + signal: null, + }; + }, }, - }, - ); + ); - expect(spawnedEnv?.[name]).toBeUndefined(); + expect(spawnedEnv?.[name]).toBeUndefined(); - expect(spawnedEnv?.NO_PROXY).toBe("*"); - expect(spawnedEnv?.no_proxy).toBe("*"); - }); + expect(spawnedEnv?.NO_PROXY).toBe("*"); + expect(spawnedEnv?.no_proxy).toBe("*"); + }, + ); it.each([ "http://127.0.0.1:8000/v1/models", @@ -620,7 +621,11 @@ describe("http-probe helpers", () => { }, }); - expect(["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"].every((name) => spawnedEnv?.[name] === undefined)).toBe(true); + expect( + ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"].every( + (name) => spawnedEnv?.[name] === undefined, + ), + ).toBe(true); expect(spawnedEnv?.NO_PROXY).toBe("*"); expect(spawnedEnv?.no_proxy).toBe("*"); }); diff --git a/src/lib/adapters/openshell/README.md b/src/lib/adapters/openshell/README.md index a48c053bf78..76568e54a2b 100644 --- a/src/lib/adapters/openshell/README.md +++ b/src/lib/adapters/openshell/README.md @@ -10,8 +10,7 @@ issue #10938 and PR #11065. They do not complete the capability migrations in th | --- | --- | --- | | Provider endpoint and identity | `providers.ts` | SDK `raw.getProvider`, plus `raw.getProviderProfile` for native NVIDIA inference without overrides; the pinned SDK has no curated gateway-provider read. Reuses the metadata fields from `provider-adapter.ts` (#9806, #9825). | | Sandbox identity, image, and attachments | `sandboxes.ts` | SDK `raw.getSandbox`; curated `sandbox.get` omits workspace, image, and active policy version. | -| Configuration identity | `sandbox-config.ts` | SDK `raw.getSandboxConfig` by verified ID; curated `sandbox.getConfig` does a new name lookup and omits workspace. | -| Effective policy document and applied revision | `sandbox-policy.ts` and `sandbox-policy-cli.ts` | Retains the existing policy contract from #10150. YAML conversion and policy migration remain with #9805 and #9826. | +| Configuration identity and effective policy | `sandbox-config.ts` | SDK `raw.getSandboxConfig` by verified ID returns both in one response; curated `sandbox.getConfig` does a new name lookup and omits workspace. Policy reads for other consumers remain with #9805 and #9826. | | Inference route | `inference/live.ts` | Retains the CLI read with an explicit gateway. The separate generated inference client remains with #9809 and #9828. | | Managed workload and gateway ownership | NemoClaw registry and gateway state | These are NemoClaw provenance, not OpenShell resource fields. | @@ -27,6 +26,8 @@ strings so uint64 values cannot lose precision. `sdk-read-schema.ts` defines TypeBox schemas for consumed response fields. Validate before projection, keep credential values opaque, and check response identities against the request. +Policy protobuf JSON passes through a consumed-field schema before document conversion. Converter +input types come from that schema; fields outside the conversion stay with the complete policy validator. Schema failures use fixed messages without rejected values. Provider reads return credential names and requested non-secret config values. @@ -38,7 +39,11 @@ as the endpoint evidence. Custom profiles, profile scope changes, and provider c cannot use this derivation. Provider reads retain the complete config-key inventory so export can reject unsupported configuration. Sandbox reads omit -environment values. Configuration reads return revision metadata, not settings or credential values. +environment values. Configuration reads return revision metadata and a credential-free effective policy document, without settings values. +The policy conversion follows the reviewed OpenShell release: filesystem defaults, compact ports, +query matchers, MCP selectors, and provider-composed rules retain their document meaning. Unknown +protobuf fields are rejected instead of omitted. Global policy revisions use the same precedence as +`policy get --full`; export still requires agreement with the observed sandbox and configuration revision. Export compares two complete observations and can repeat that pair once when state changes. Managed rebuild recovery and snapshot-clone provider inspection, profile import, and creation use @@ -46,3 +51,5 @@ Managed rebuild recovery and snapshot-clone provider inspection, profile import, detachment, deletion, replacement cleanup, and other lifecycle operations keep their existing adapters until the remaining #9806 migration slices land. This does not claim SDK qualification for those operations. + +Policy export rejects SDK messages and serialized YAML above 1 MiB. It checks cancellation before conversion and after SDK loading. OpenShell SDK 0.0.106 does not expose a transport receive-size option. diff --git a/src/lib/adapters/openshell/client.ts b/src/lib/adapters/openshell/client.ts index e9c04395b9f..f52da469a3a 100644 --- a/src/lib/adapters/openshell/client.ts +++ b/src/lib/adapters/openshell/client.ts @@ -416,12 +416,12 @@ export function captureOpenshellCommandAsyncResult( let stderrBytes = 0; const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; - const outputLimitBytes = - opts.outputLimitBytes === undefined || opts.outputLimitBytes === Number.POSITIVE_INFINITY - ? Number.POSITIVE_INFINITY - : Number.isFinite(opts.outputLimitBytes) - ? Math.max(0, opts.outputLimitBytes) - : 0; + let outputLimitBytes = 0; + if (opts.outputLimitBytes === undefined || opts.outputLimitBytes === Number.POSITIVE_INFINITY) { + outputLimitBytes = Number.POSITIVE_INFINITY; + } else if (Number.isFinite(opts.outputLimitBytes)) { + outputLimitBytes = Math.max(0, opts.outputLimitBytes); + } const killGraceMs = opts.killGraceMs ?? 1000; const clearTimers = () => { diff --git a/src/lib/adapters/openshell/forward-service.test.ts b/src/lib/adapters/openshell/forward-service.test.ts index abe50174d36..45d7c497f87 100644 --- a/src/lib/adapters/openshell/forward-service.test.ts +++ b/src/lib/adapters/openshell/forward-service.test.ts @@ -555,7 +555,9 @@ setInterval(() => {}, 1000); launchError = error; } - expect(launchError).toEqual(expect.objectContaining({ message: expect.stringMatching(/did not bind/u) })); + expect(launchError).toEqual( + expect.objectContaining({ message: expect.stringMatching(/did not bind/u) }), + ); expect(existsSync(markerPath)).toBe(true); expect(existsSync(releasePath)).toBe(true); const pids = JSON.parse(readFileSync(markerPath, "utf8")) as { diff --git a/src/lib/adapters/openshell/forward-service.ts b/src/lib/adapters/openshell/forward-service.ts index f0814b44e3c..23ed2c30bb2 100644 --- a/src/lib/adapters/openshell/forward-service.ts +++ b/src/lib/adapters/openshell/forward-service.ts @@ -189,7 +189,7 @@ function linuxListenerPids(port: number, procRoot: string, workLimit: number): s fields[1]?.toUpperCase().endsWith(portSuffix) && /^\d+$/u.test(fields[9] ?? "") ) { - socketInodes.add(fields[9]!); + socketInodes.add(fields[9]); } } } catch { @@ -208,7 +208,7 @@ function linuxListenerPids(port: number, procRoot: string, workLimit: number): s if (++inspected > workLimit) return []; const link = readlinkSync(path.join(procRoot, entry.name, "fd", descriptor)); const match = /^socket:\[(\d+)\]$/u.exec(link); - if (match && socketInodes.has(match[1]!)) { + if (match && socketInodes.has(match[1])) { pids.add(entry.name); break; } @@ -273,8 +273,8 @@ export function isForwardServiceListenerOwner( const procRoot = options.procRoot ?? "/proc"; const procWorkLimit = options.procWorkLimit ?? LINUX_PROC_WORK_LIMIT; const before = listenerPids(target.localPort, platform, procRoot, procWorkLimit, probe); - if (before.length !== 1 || !/^[1-9]\d*$/u.test(before[0]!)) return false; - const pid = before[0]!; + if (before.length !== 1 || !/^[1-9]\d*$/u.test(before[0])) return false; + const pid = before[0]; if (!processExecutableMatches(pid, target, platform, procRoot, probe)) return false; const commandLine = probe("ps", ["-ww", "-p", pid, "-o", "args="]); if (commandLine.status !== 0) return false; diff --git a/src/lib/adapters/openshell/gateway-drift.test.ts b/src/lib/adapters/openshell/gateway-drift.test.ts index 214e29ddf3f..b9786d46e5c 100644 --- a/src/lib/adapters/openshell/gateway-drift.test.ts +++ b/src/lib/adapters/openshell/gateway-drift.test.ts @@ -143,10 +143,7 @@ describe("OpenShell gateway drift preflight", () => { .spyOn(docker, "dockerContainerInspectFormat") .mockReturnValueOnce("true") .mockReturnValue('{"30051/tcp":[{"HostIp":"0.0.0.0","HostPort":"9090"}]}'); - spies.push( - captureOpenshell, - inspectContainer, - ); + spies.push(captureOpenshell, inspectContainer); const runtimeSelection = { gatewayName: "nemoclaw-9090", localTlsDir: "/authority/tls", @@ -173,11 +170,7 @@ describe("OpenShell gateway drift preflight", () => { ["gateway", "info", "-g", "nemoclaw-9090"], selectedProbeOptions, ); - expect(captureOpenshell).toHaveBeenNthCalledWith( - 3, - ["gateway", "info"], - selectedProbeOptions, - ); + expect(captureOpenshell).toHaveBeenNthCalledWith(3, ["gateway", "info"], selectedProbeOptions); const firstProbeOptions = captureOpenshell.mock.calls[0]?.[1] as | { env?: Record } | undefined; @@ -343,21 +336,24 @@ describe("OpenShell gateway drift preflight", () => { ["0.0.44", "compatible"], ["0.0.43", "drift"], [null, "unknown"], - ] as const)("reports host-process running version %s as %s readiness evidence", (runningVersion, expected) => { - expect( - observeOpenShellGatewayVersionCompatibility({ - source: "host-process", - deps: { - getInstalledOpenshellVersion: () => "0.0.44", - getGatewayClusterImageRef: () => null, - getHostProcessGatewayRuntime: () => ({ - gatewayBin: "/home/u/.local/bin/openshell-gateway", - runningVersion, - }), - }, - }), - ).toBe(expected); - }); + ] as const)( + "reports host-process running version %s as %s readiness evidence", + (runningVersion, expected) => { + expect( + observeOpenShellGatewayVersionCompatibility({ + source: "host-process", + deps: { + getInstalledOpenshellVersion: () => "0.0.44", + getGatewayClusterImageRef: () => null, + getHostProcessGatewayRuntime: () => ({ + gatewayBin: "/home/u/.local/bin/openshell-gateway", + runningVersion, + }), + }, + }), + ).toBe(expected); + }, + ); it("keeps version compatibility unknown without an installed version", () => { expect( diff --git a/src/lib/adapters/openshell/provider-adapter-cli.test.ts b/src/lib/adapters/openshell/provider-adapter-cli.test.ts index c40ba9c261b..bed4fb2f015 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.test.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.test.ts @@ -119,7 +119,7 @@ describe("CLI OpenShell provider adapter", () => { }), ]; - const results = await Promise.all(operations); + const results = await Promise.all(operations.map((operation) => Promise.resolve(operation))); const expectedFailure = { ok: false, diff --git a/src/lib/adapters/openshell/provider-adapter-cli.ts b/src/lib/adapters/openshell/provider-adapter-cli.ts index 2fa0dd92259..a1068b865b4 100644 --- a/src/lib/adapters/openshell/provider-adapter-cli.ts +++ b/src/lib/adapters/openshell/provider-adapter-cli.ts @@ -12,7 +12,6 @@ import { import { type AttachOpenShellProviderRequest, type ConfigureOpenShellProviderRefreshRequest, - type CreateOpenShellProviderRequest, type DeleteOpenShellProviderRequest, type DetachOpenShellProviderRequest, type GetOpenShellProviderRequest, @@ -131,14 +130,14 @@ function rawCommandOutput(result: CapturedProviderCommandResult): string { const streams = [bufferOrStringToText(result.stderr), bufferOrStringToText(result.stdout)].filter( Boolean, ); - return streams.length > 0 - ? streams.join("\n") - : Array.isArray(result.output) - ? [result.output[2], result.output[1]] - .map((value) => bufferOrStringToText(value as string | Buffer | null | undefined)) - .filter(Boolean) - .join("\n") - : bufferOrStringToText(result.output as string | Buffer | null | undefined); + if (streams.length > 0) return streams.join("\n"); + if (Array.isArray(result.output)) { + return [result.output[2], result.output[1]] + .map((value) => bufferOrStringToText(value as string | Buffer | null | undefined)) + .filter(Boolean) + .join("\n"); + } + return bufferOrStringToText(result.output as string | Buffer | null | undefined); } function commandOutput(result: CapturedProviderCommandResult): string { diff --git a/src/lib/adapters/openshell/provider-diagnostic-cli.ts b/src/lib/adapters/openshell/provider-diagnostic-cli.ts index 9133603354f..a991a065eb3 100644 --- a/src/lib/adapters/openshell/provider-diagnostic-cli.ts +++ b/src/lib/adapters/openshell/provider-diagnostic-cli.ts @@ -120,11 +120,9 @@ function providerNameFromNotFoundText(text: string): string | null { const quoted = readQuotedValue(text, providerPrefix.length); if (!quoted) return null; const suffix = normalizedNotFoundSuffix(text.slice(quoted.end)); - return suffix === "" && hasNotFoundStatusPrefix + return (suffix === "" && hasNotFoundStatusPrefix) || NOT_FOUND_SUFFIXES.has(suffix) ? quoted.value - : NOT_FOUND_SUFFIXES.has(suffix) - ? quoted.value - : null; + : null; } function providerNameFromMessage(line: string): string | null { diff --git a/src/lib/adapters/openshell/providers.test.ts b/src/lib/adapters/openshell/providers.test.ts index b06a1d67693..3b9e5f4651a 100644 --- a/src/lib/adapters/openshell/providers.test.ts +++ b/src/lib/adapters/openshell/providers.test.ts @@ -392,7 +392,7 @@ describe("OpenShell sandbox export evidence", () => { policySource, globalPolicyVersion: policySource === 2 ? 4 : 0, }); - const result = await createSandboxConfig(connect).get(input); + const result = await createSandboxConfig(connect, async () => "version: 1\n").get(input); expect(result).toMatchObject({ sandboxId: "verified-id", workspace: "default", @@ -400,6 +400,7 @@ describe("OpenShell sandbox export evidence", () => { configRevision: "18446744073709551615", providerEnvRevision: "9007199254740993", policySource: policySource === 1 ? "sandbox" : "global", + policy: { document: "version: 1\n", appliedRevision: policySource === 1 ? 3 : 4 }, }); expect(raw.getSandboxConfig).toHaveBeenCalledWith( { sandboxId: "verified-id" }, @@ -409,6 +410,25 @@ describe("OpenShell sandbox export evidence", () => { }, ); + it("does not serialize a configuration response that arrives after abort", async () => { + const { connect, raw } = fixture(); + const response = await raw.getSandboxConfig(); + const controller = new AbortController(); + raw.getSandboxConfig.mockImplementation(async () => { + controller.abort(); + return response; + }); + const serialize = vi.fn(async () => "version: 1\n"); + await expect( + createSandboxConfig(connect, serialize).get({ + ...request(), + sandboxId: "verified-id", + signal: controller.signal, + }), + ).rejects.toMatchObject({ kind: "timeout" }); + expect(serialize).not.toHaveBeenCalled(); + }); + it.each([ { workspace: "other" }, { policySource: 0 }, diff --git a/src/lib/adapters/openshell/resolve.test.ts b/src/lib/adapters/openshell/resolve.test.ts index a236803431c..8bab00ea924 100644 --- a/src/lib/adapters/openshell/resolve.test.ts +++ b/src/lib/adapters/openshell/resolve.test.ts @@ -19,11 +19,7 @@ function executableAuthorityHarness() { let executableInode = 10n; let executableBytes = Buffer.from("openshell-binary"); let parentInode = 20n; - const stat = ( - kind: "directory" | "file", - inode: bigint, - size = 0n, - ): PodmanExecutableStat => ({ + const stat = (kind: "directory" | "file", inode: bigint, size = 0n): PodmanExecutableStat => ({ dev: 1n, ino: inode, mode: kind === "file" ? 0o100755n : 0o40755n, @@ -171,9 +167,7 @@ describe("lib/resolve-openshell", () => { }); it("lists every fallback candidate when OpenShell is unavailable (#9805)", () => { - expect( - openshellNotFoundDiagnosticLines({ HOME: "/fakehome", PATH: "/usr/bin" }), - ).toEqual( + expect(openshellNotFoundDiagnosticLines({ HOME: "/fakehome", PATH: "/usr/bin" })).toEqual( expect.arrayContaining([ " - /fakehome/.local/bin/openshell", " - /opt/homebrew/bin/openshell", @@ -209,12 +203,7 @@ describe("Hermes portable OpenShell executable authority", () => { expect(authority.version).toBe("0.0.106"); expect(authority.executable.executablePath).toBe(AUTHORITY_BINARY); expect( - assertHermesPortableOpenShellExecutableAuthority( - authority, - childEnv, - childEnv, - harness.deps, - ), + assertHermesPortableOpenShellExecutableAuthority(authority, childEnv, childEnv, harness.deps), ).toBe(AUTHORITY_BINARY); }); @@ -284,27 +273,17 @@ describe("Hermes portable OpenShell executable authority", () => { harness.changeDigest(); expect(() => - assertHermesPortableOpenShellExecutableAuthority( - authority, - childEnv, - childEnv, - harness.deps, - ), + assertHermesPortableOpenShellExecutableAuthority(authority, childEnv, childEnv, harness.deps), ).toThrow("executable generation changed after reservation"); }); it("rejects OpenShell 0.0.101 before authority capture or reuse (#9211)", () => { const harness = executableAuthorityHarness(); expect(() => - captureHermesPortableOpenShellExecutableAuthority( - AUTHORITY_BINARY, - childEnv, - childEnv, - { - ...harness.deps, - runVersion: () => ({ status: 0, stdout: "openshell 0.0.101\n", stderr: "" }), - }, - ), + captureHermesPortableOpenShellExecutableAuthority(AUTHORITY_BINARY, childEnv, childEnv, { + ...harness.deps, + runVersion: () => ({ status: 0, stdout: "openshell 0.0.101\n", stderr: "" }), + }), ).toThrow("requires OpenShell 0.0.106"); const reuseHarness = executableAuthorityHarness(); @@ -315,15 +294,10 @@ describe("Hermes portable OpenShell executable authority", () => { reuseHarness.deps, ); expect(() => - assertHermesPortableOpenShellExecutableAuthority( - authority, - childEnv, - childEnv, - { - ...reuseHarness.deps, - runVersion: () => ({ status: 0, stdout: "openshell 0.0.101\n", stderr: "" }), - }, - ), + assertHermesPortableOpenShellExecutableAuthority(authority, childEnv, childEnv, { + ...reuseHarness.deps, + runVersion: () => ({ status: 0, stdout: "openshell 0.0.101\n", stderr: "" }), + }), ).toThrow("requires OpenShell 0.0.106"); }); }); diff --git a/src/lib/adapters/openshell/resolve.ts b/src/lib/adapters/openshell/resolve.ts index adce65017ce..f566c784dbf 100644 --- a/src/lib/adapters/openshell/resolve.ts +++ b/src/lib/adapters/openshell/resolve.ts @@ -34,11 +34,7 @@ export function openshellNotFoundDiagnosticLines( if (sourceEnv.HOME?.startsWith("/")) { checked.push(`${sourceEnv.HOME}/.local/bin/openshell`); } - checked.push( - "/opt/homebrew/bin/openshell", - "/usr/local/bin/openshell", - "/usr/bin/openshell", - ); + checked.push("/opt/homebrew/bin/openshell", "/usr/local/bin/openshell", "/usr/bin/openshell"); return [ " openshell binary not found. Checked:", ...checked.map((location) => ` - ${location}`), diff --git a/src/lib/adapters/openshell/restore-gateway-pairing.test.ts b/src/lib/adapters/openshell/restore-gateway-pairing.test.ts index d85d5c95fd2..3107ba3a86a 100644 --- a/src/lib/adapters/openshell/restore-gateway-pairing.test.ts +++ b/src/lib/adapters/openshell/restore-gateway-pairing.test.ts @@ -50,15 +50,18 @@ describe("verifyRestoredSandboxGatewayPairing", () => { "gateway connect failed: pairing required: device is asking for more scopes", "scope-upgrade-pending", ], - ] as const)("classifies a verification run with fallback or pairing output (#7431)", (output, failureLayer) => { - expect( - verifyRestoredSandboxGatewayPairing( - "beta", - SESSION_ID_PREFIX, - verifierDeps({ status: 0, stdout: output }), - ), - ).toEqual({ ok: false, failureLayer }); - }); + ] as const)( + "classifies a verification run with fallback or pairing output (#7431)", + (output, failureLayer) => { + expect( + verifyRestoredSandboxGatewayPairing( + "beta", + SESSION_ID_PREFIX, + verifierDeps({ status: 0, stdout: output }), + ), + ).toEqual({ ok: false, failureLayer }); + }, + ); it("accepts changed output when the gateway run exits successfully without a failure signal (#7431)", () => { expect( diff --git a/src/lib/adapters/openshell/runtime.test.ts b/src/lib/adapters/openshell/runtime.test.ts index af231f9334c..87970a81631 100644 --- a/src/lib/adapters/openshell/runtime.test.ts +++ b/src/lib/adapters/openshell/runtime.test.ts @@ -211,7 +211,7 @@ describe("sanitized OpenShell capture", () => { 'if (argv.includes("--output")) {', ' process.stdout.write(JSON.stringify({ scope: "sandbox", sandbox: "runtime-policy", status: "effective", policy_source: "sandbox", hash: `sha256:${activeVersion}`, active_version: activeVersion, policy: JSON.parse(document) }));', "} else {", - ' process.stdout.write(`Version: ${activeVersion}\\nActive: ${activeVersion}\\n---\\n${document}\\n`);', + " process.stdout.write(`Version: ${activeVersion}\\nActive: ${activeVersion}\\n---\\n${document}\\n`);", "}", ].join("\n"), ); diff --git a/src/lib/adapters/openshell/runtime.ts b/src/lib/adapters/openshell/runtime.ts index c2f23adf233..401df226619 100644 --- a/src/lib/adapters/openshell/runtime.ts +++ b/src/lib/adapters/openshell/runtime.ts @@ -29,11 +29,7 @@ export { withSelectedOpenShellCommandOptions, } from "./command-argv"; -export { - buildOpenShellSubprocessEnv, - OPENSHELL_OPERATION_TIMEOUT_MS, - OPENSHELL_PROBE_TIMEOUT_MS, -}; +export { buildOpenShellSubprocessEnv, OPENSHELL_OPERATION_TIMEOUT_MS, OPENSHELL_PROBE_TIMEOUT_MS }; export { classifyManagedGatewayEndpointBinding } from "./client"; export { runCaptureEx } from "../../runner"; diff --git a/src/lib/adapters/openshell/sandbox-command-cli.ts b/src/lib/adapters/openshell/sandbox-command-cli.ts index 34b6bd4cdda..ecf86e49a59 100644 --- a/src/lib/adapters/openshell/sandbox-command-cli.ts +++ b/src/lib/adapters/openshell/sandbox-command-cli.ts @@ -17,6 +17,7 @@ import { type OpenShellSandboxBufferedCommandRequest, type OpenShellSandboxBufferedCommandExecutor, type OpenShellSandboxCommandCompletion, + type OpenShellSandboxCommandError, type OpenShellSandboxCommandExecutor, type OpenShellSandboxCommandRequest, type OpenShellSandboxCommandOutcome, @@ -186,11 +187,8 @@ export const runCliOpenShellBufferedCommand: OpenShellBufferedCommandRunner = as signal: result.signal ?? result.timeoutSignal ?? null, stdout: result.stdout, stderr: result.stderr, - ...(result.timedOut - ? { timedOut: true } - : result.error - ? { error: result.error, timedOut: false } - : {}), + ...(result.timedOut ? { timedOut: true } : {}), + ...(!result.timedOut && result.error ? { error: result.error, timedOut: false } : {}), }; } catch (error) { return { @@ -243,21 +241,15 @@ export async function runCliOpenShellStreamingCommand( }); } -function commandError(error: Error) { +function commandError(error: Error): OpenShellSandboxCommandError { const code = (error as NodeJS.ErrnoException).code; - return { - kind: - code === "ENOENT" - ? "unavailable" - : code === "ECANCELED" - ? "cancelled" - : code === "ETIMEDOUT" - ? "timeout" - : code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" - ? "capture" - : "invocation", - message: error.message, - } as const; + if (code === "ENOENT") return { kind: "unavailable", message: error.message }; + if (code === "ECANCELED") return { kind: "cancelled", message: error.message }; + if (code === "ETIMEDOUT") return { kind: "timeout", message: error.message }; + if (code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") { + return { kind: "capture", message: error.message }; + } + return { kind: "invocation", message: error.message }; } function commandFailure(error: Error): OpenShellSandboxCommandOutcome { @@ -267,18 +259,21 @@ function commandFailure(error: Error): OpenShellSandboxCommandOutcome { function bufferedCommandCompletion( result: OpenShellBufferedCommandRunResult, ): OpenShellSandboxBufferedCommandCompletion { - const outcome = result.timedOut - ? { - kind: "failed" as const, - error: { kind: "timeout" as const, message: "OpenShell command timed out" }, - } - : result.error - ? commandFailure(result.error) - : { - kind: "completed" as const, - exitCode: spawnExitCode(result), - ...(result.signal ? { signal: result.signal } : {}), - }; + let outcome: OpenShellSandboxCommandOutcome; + if (result.timedOut) { + outcome = { + kind: "failed", + error: { kind: "timeout", message: "OpenShell command timed out" }, + }; + } else if (result.error) { + outcome = commandFailure(result.error); + } else { + outcome = { + kind: "completed", + exitCode: spawnExitCode(result), + ...(result.signal ? { signal: result.signal } : {}), + }; + } return { outcome, stdout: result.stdout, stderr: result.stderr }; } diff --git a/src/lib/adapters/openshell/sandbox-command-sdk.ts b/src/lib/adapters/openshell/sandbox-command-sdk.ts index 5e6ee64db92..10d5128ee9f 100644 --- a/src/lib/adapters/openshell/sandbox-command-sdk.ts +++ b/src/lib/adapters/openshell/sandbox-command-sdk.ts @@ -10,6 +10,7 @@ import { import { isValidName } from "../../sandbox-name-contract"; import type { OpenShellSandboxCommandCompletion, + OpenShellSandboxCommandError, OpenShellSandboxCommandOutcome, OpenShellSandboxCommandRequest, } from "./sandbox-command"; @@ -49,19 +50,16 @@ function commandFailure(error: unknown): OpenShellSandboxCommandOutcome { typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code) : ""; - return { - kind: "failed", - error: { - kind: - error instanceof OpenShellSdkPreflightUnavailableError || - /Cannot find (?:module|package) ['"]@nvidia\/openshell-sdk['"]/u.test(message) - ? "unavailable" - : /timeout|deadline/iu.test(`${code} ${message}`) - ? "timeout" - : "invocation", - message, - }, - }; + let kind: OpenShellSandboxCommandError["kind"] = "invocation"; + if ( + error instanceof OpenShellSdkPreflightUnavailableError || + /Cannot find (?:module|package) ['"]@nvidia\/openshell-sdk['"]/u.test(message) + ) { + kind = "unavailable"; + } else if (/timeout|deadline/iu.test(`${code} ${message}`)) { + kind = "timeout"; + } + return { kind: "failed", error: { kind, message } }; } function assertRequestName(name: string, label: string): void { diff --git a/src/lib/adapters/openshell/sandbox-config.test.ts b/src/lib/adapters/openshell/sandbox-config.test.ts new file mode 100644 index 00000000000..e5105ea935a --- /dev/null +++ b/src/lib/adapters/openshell/sandbox-config.test.ts @@ -0,0 +1,250 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { sdkPolicyDocument } from "./sandbox-config"; + +function document(endpoint: Record) { + return sdkPolicyDocument({ + version: 1, + network_policies: { + api: { name: "api", endpoints: [endpoint], binaries: [{ path: "/usr/bin/curl" }] }, + }, + }); +} + +function exportedEndpoint(endpoint: Record) { + return (document(endpoint).network_policies as { api: { endpoints: unknown[] } }).api + .endpoints[0]; +} + +describe("SDK policy document conversion", () => { + it.each([ + ["version", { version: "1" }], + ["filesystem", { filesystem: false }], + ["workdir flag", { filesystem: { include_workdir: "false" } }], + ["filesystem paths", { filesystem: { read_only: [42] } }], + ["process identity", { process: { run_as_user: 42 } }], + ["network policy map", { network_policies: [] }], + ["network rule", { network_policies: { api: false } }], + ["endpoint list", { network_policies: { api: { endpoints: {} } } }], + ["binary path", { network_policies: { api: { binaries: [{ path: 42 }] } } }], + ])("rejects a malformed %s before policy conversion", (_field, input) => { + expect(() => sdkPolicyDocument(input)).toThrow("OpenShell read failed (schema)."); + }); + + it.each([ + ["host", { host: 123 }], + ["port", { port: "443" }], + ["port list", { ports: ["443"] }], + ["negative port", { port: -1 }], + ["fractional port", { port: 443.5 }], + ["uint32 overflow", { port: 4294967296 }], + ["protocol", { protocol: false }], + ["MCP options", { mcp: false }], + ["MCP strict tool names", { mcp: { strict_tool_names: "false" } }], + ["MCP method profile", { mcp: { allow_all_known_mcp_methods: "false" } }], + ["body limit", { json_rpc_max_body_bytes: "4096" }], + ["rules", { rules: {} }], + ["allow matcher", { rules: [{ allow: false }] }], + ["method", { rules: [{ allow: { method: 42 } }] }], + ["query glob", { rules: [{ allow: { query: { repo: { glob: 42 } } } }] }], + ["parameter glob", { rules: [{ allow: { params: { name: { glob: 42 } } } }] }], + ["parameter choices", { rules: [{ allow: { params: { name: { any: [false] } } } }] }], + ["deny matcher", { deny_rules: [false] }], + ])("rejects a malformed endpoint %s before conversion", (_field, input) => { + expect(() => document(input)).toThrow("OpenShell read failed (schema)."); + }); + + it("accepts omitted protobuf defaults without inventing optional sections", () => { + expect(sdkPolicyDocument({})).toEqual({ version: 0 }); + expect(exportedEndpoint({ rules: [{ allow: { params: { name: {} } } }] })).toEqual({ + rules: [{ allow: { params: { name: "" } } }], + }); + }); + + it("preserves untouched policy fields without mutating the input", () => { + const endpoint = { + protocol: "mcp", + tls: "terminate", + allowed_ips: ["192.0.2.1"], + json_rpc_max_body_bytes: 4096, + mcp: { strict_tool_names: false }, + }; + const before = structuredClone(endpoint); + expect(exportedEndpoint(endpoint)).toEqual({ + protocol: "mcp", + tls: "terminate", + allowed_ips: ["192.0.2.1"], + mcp: { strict_tool_names: false, max_body_bytes: 4096 }, + }); + expect(endpoint).toEqual(before); + }); + + it("preserves filesystem defaults and omits an empty process identity", () => { + expect( + sdkPolicyDocument({ + version: 1, + filesystem: { read_only: ["/usr"] }, + process: {}, + landlock: {}, + }), + ).toEqual({ + version: 1, + filesystem_policy: { include_workdir: false, read_only: ["/usr"] }, + landlock: {}, + }); + }); + + it.each([ + [{ port: 443 }, { port: 443 }], + [{ ports: [443] }, { port: 443 }], + [{ port: 80, ports: [443, 8443] }, { ports: [443, 8443] }], + ])("preserves effective ports for %j", (input, expected) => { + expect(exportedEndpoint({ host: "api.example", ...input })).toEqual({ + host: "api.example", + ...expected, + }); + }); + + it("preserves REST allow, deny, and query restrictions", () => { + expect( + exportedEndpoint({ + host: "api.example", + port: 443, + protocol: "rest", + tls: "terminate", + rules: [ + { + allow: { + method: "GET", + path: "/v1/*", + query: { repo: { glob: "NVIDIA/*" }, scope: { any: ["read", "list"] } }, + }, + }, + ], + deny_rules: [{ method: "DELETE", path: "/v1/*" }], + }), + ).toEqual({ + host: "api.example", + port: 443, + protocol: "rest", + tls: "terminate", + rules: [ + { + allow: { + method: "GET", + path: "/v1/*", + query: { repo: "NVIDIA/*", scope: { any: ["read", "list"] } }, + }, + }, + ], + deny_rules: [{ method: "DELETE", path: "/v1/*" }], + }); + }); + + it("preserves explicit false MCP options and tool restrictions", () => { + expect( + exportedEndpoint({ + protocol: "mcp", + json_rpc_max_body_bytes: 4096, + mcp: { strict_tool_names: false, allow_all_known_mcp_methods: false }, + rules: [ + { + allow: { + method: "tools/call", + params: { + name: { glob: "search" }, + "arguments.repo": { glob: "NVIDIA/*" }, + "arguments.limit": { any: ["1", "2"] }, + }, + }, + }, + ], + }), + ).toEqual({ + protocol: "mcp", + mcp: { max_body_bytes: 4096, strict_tool_names: false, allow_all_known_mcp_methods: false }, + rules: [ + { + allow: { + method: "tools/call", + tool: "search", + params: { arguments: { repo: "NVIDIA/*", limit: { any: ["1", "2"] } } }, + }, + }, + ], + }); + }); + + it("uses the MCP method profile only when enabled", () => { + expect( + exportedEndpoint({ + protocol: "mcp", + mcp: { allow_all_known_mcp_methods: true }, + rules: [ + { allow: { method: "tools/call", params: { name: { glob: "search" } } } }, + { allow: { method: "*" } }, + ], + deny_rules: [{ method: "tools/call", params: { name: { any: ["delete", "write"] } } }], + }), + ).toEqual({ + protocol: "mcp", + mcp: { allow_all_known_mcp_methods: true }, + rules: [{ allow: { tool: "search" } }, { allow: {} }], + deny_rules: [{ tool: { any: ["delete", "write"] } }], + }); + }); + + it("keeps colliding MCP parameter paths flat without losing restrictions", () => { + expect( + exportedEndpoint({ + protocol: "mcp", + rules: [{ allow: { params: { a: { glob: "x" }, "a.b": { glob: "y" } } } }], + }), + ).toEqual({ + protocol: "mcp", + rules: [{ allow: { params: { a: "x", "a.b": "y" } } }], + }); + }); + + it("preserves JSON-RPC body limits and flat parameter keys", () => { + expect( + exportedEndpoint({ + protocol: "json-rpc", + json_rpc_max_body_bytes: 4096, + rules: [ + { allow: { method: "read", params: { name: { glob: "x" }, "a.b": { glob: "y" } } } }, + ], + }), + ).toEqual({ + protocol: "json-rpc", + json_rpc: { max_body_bytes: 4096 }, + rules: [{ allow: { method: "read", params: { name: "x", "a.b": "y" } } }], + }); + }); + + it("preserves provider-composed rules and ignores the deprecated binary harness flag", () => { + expect( + sdkPolicyDocument({ + version: 1, + network_policies: { + "provider:nvidia": { + name: "provider:nvidia", + endpoints: [{ host: "api.example", port: 443 }], + binaries: [{ path: "/usr/bin/curl", harness: true }], + }, + }, + }), + ).toEqual({ + version: 1, + network_policies: { + "provider:nvidia": { + name: "provider:nvidia", + endpoints: [{ host: "api.example", port: 443 }], + binaries: [{ path: "/usr/bin/curl" }], + }, + }, + }); + }); +}); diff --git a/src/lib/adapters/openshell/sandbox-config.ts b/src/lib/adapters/openshell/sandbox-config.ts index 8a57e0453b1..72b62c6b5b4 100644 --- a/src/lib/adapters/openshell/sandbox-config.ts +++ b/src/lib/adapters/openshell/sandbox-config.ts @@ -1,8 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import YAML from "yaml"; +import { sortCanonicalMappings } from "../../config/canonical-mapping"; +import { isSandboxPolicyCredentialFree } from "../../policy/sandbox-policy-validation"; import type { SandboxConfiguration } from "../../domain/sandbox/configuration"; -import { SandboxConfigResponseSchema } from "./sdk-read-schema"; +import type { OpenShellSandboxPolicyRead } from "./sandbox-policy"; +import { + PolicyJsonSchema, + SandboxConfigResponseSchema, + type PolicyEndpointJson, + type PolicyMatcherJson, +} from "./sdk-read-schema"; import { connectOpenShellReader, OpenShellReadError, @@ -14,10 +23,15 @@ import { type ReadRequest, } from "./sdk-read"; -/** Configuration identity complements the existing effective-policy reader (#9805, #9826). */ -export function createSandboxConfig(connect: ConnectOpenShellReader = connectOpenShellReader) { +/** Read configuration identity and its effective policy in the same gateway response. */ +export function createSandboxConfig( + connect: ConnectOpenShellReader = connectOpenShellReader, + serializePolicy: (policy: unknown, signal?: AbortSignal) => Promise = serializeSdkPolicy, +) { return { - get: (request: ReadRequest & Readonly<{ sandboxId: string }>): Promise => + get: ( + request: ReadRequest & Readonly<{ sandboxId: string }>, + ): Promise => readOpenShell(request, async () => { const sandboxId = text(request.sandboxId); const client = await connect(request.target); @@ -30,6 +44,9 @@ export function createSandboxConfig(connect: ConnectOpenShellReader = connectOpe if (config.workspace !== request.workspace) { throw new OpenShellReadError("schema"); } + request.signal.throwIfAborted(); + const document = await serializePolicy(config.policy, request.signal); + request.signal.throwIfAborted(); return owned({ sandboxId, workspace: request.workspace, @@ -39,7 +56,177 @@ export function createSandboxConfig(connect: ConnectOpenShellReader = connectOpe providerEnvRevision: String(config.providerEnvRevision), policySource: config.policySource === 1 ? "sandbox" : "global", globalPolicyVersion: config.globalPolicyVersion, + policy: { + document, + appliedRevision: + config.policySource === 2 && config.globalPolicyVersion > 0 + ? config.globalPolicyVersion + : config.version, + }, }); }), }; } + +type PolicyDocument = Record; +type ParameterMatcher = string | { any: string[] }; +type ParameterTree = { [key: string]: ParameterMatcher | ParameterTree }; + +function rejectUnknownWireFields(value: unknown): void { + if (!value || typeof value !== "object") return; + if ("$unknown" in value && (!Array.isArray(value.$unknown) || value.$unknown.length > 0)) { + throw new OpenShellReadError("schema"); + } + for (const child of Object.values(value)) rejectUnknownWireFields(child); +} + +function matchers(value: PolicyMatcherJson["params"]): Record { + return Object.fromEntries( + Object.entries(value ?? {}).map(([key, matcher]) => [ + key, + matcher.any?.length ? { any: matcher.any } : (matcher.glob ?? ""), + ]), + ); +} + +function nestedParams(flat: Record): ParameterTree { + const root: ParameterTree = Object.create(null); + const branches = new WeakMap>(); + for (const key of Object.keys(flat).sort()) { + const parts = key.split("."); + let parent = root; + for (let index = 0; index < parts.length - 1; index++) { + const part = parts[index]; + const siblings = branches.get(parent) ?? new Map(); + if (Object.hasOwn(parent, part) && !siblings.has(part)) return flat; + const child: ParameterTree = siblings.get(part) ?? Object.create(null); + parent[part] = child; + siblings.set(part, child); + branches.set(parent, siblings); + parent = child; + } + parent[parts[parts.length - 1]] = flat[key]; + } + return root; +} + +function omittedMcpMethod( + method: PolicyMatcherJson["method"], + hasTool: boolean, + allowKnownMethods: boolean, +): boolean { + return hasTool ? allowKnownMethods && method === "tools/call" : method === "*"; +} + +function convertMatcher( + value: PolicyMatcherJson, + mcp: boolean, + allowKnownMethods: boolean, +): PolicyDocument { + const result: PolicyDocument = { ...value }; + if (value.query) result.query = matchers(value.query); + const params = matchers(value.params); + if (mcp && Object.hasOwn(params, "name")) { + result.tool = params.name; + delete params.name; + } + if (Object.keys(params).length) result.params = mcp ? nestedParams(params) : params; + else delete result.params; + if (mcp && omittedMcpMethod(value.method, Object.hasOwn(result, "tool"), allowKnownMethods)) + delete result.method; + return result; +} + +function compactEndpointPorts(endpoint: PolicyDocument, ports: PolicyEndpointJson["ports"]): void { + if (ports?.length) { + if (ports.length === 1) { + endpoint.port = ports[0]; + delete endpoint.ports; + } else delete endpoint.port; + } +} + +function convertEndpoint(value: PolicyEndpointJson): PolicyDocument { + const endpoint: PolicyDocument = { ...value }; + const mcp = value.protocol?.toLowerCase() === "mcp"; + const options: NonNullable & { max_body_bytes?: number } = { + ...value.mcp, + }; + const allowKnownMethods = options.allow_all_known_mcp_methods === true; + compactEndpointPorts(endpoint, value.ports); + if (value.json_rpc_max_body_bytes) options.max_body_bytes = value.json_rpc_max_body_bytes; + delete endpoint.json_rpc_max_body_bytes; + delete endpoint.mcp; + if (mcp && Object.keys(options).length) endpoint.mcp = options; + else if (!mcp && options.max_body_bytes) + endpoint.json_rpc = { max_body_bytes: options.max_body_bytes }; + if (value.rules) { + endpoint.rules = value.rules.map((rule) => ({ + allow: convertMatcher(rule.allow ?? {}, mcp, allowKnownMethods), + })); + } + if (value.deny_rules) { + endpoint.deny_rules = value.deny_rules.map((rule) => + convertMatcher(rule, mcp, allowKnownMethods), + ); + } + return endpoint; +} + +/** Convert released protobuf JSON to the document shape owned by openshell-policy. */ +export function sdkPolicyDocument(value: unknown): PolicyDocument { + const input = readValue(PolicyJsonSchema, value); + const policy: PolicyDocument = { ...input, version: input.version ?? 0 }; + if (input.filesystem) { + policy.filesystem_policy = { include_workdir: false, ...input.filesystem }; + delete policy.filesystem; + } + if (input.process && Object.keys(input.process).length === 0) delete policy.process; + if (input.network_policies) { + policy.network_policies = Object.fromEntries( + Object.entries(input.network_policies).map(([name, value]) => { + const rule: PolicyDocument = { ...value }; + if (value.endpoints) rule.endpoints = value.endpoints.map(convertEndpoint); + if (value.binaries) + rule.binaries = value.binaries.map((binary) => ({ path: binary.path ?? "" })); + return [name, rule]; + }), + ); + } + return policy; +} + +const MAX_POLICY_BYTES = 1024 * 1024; + +/** Keep generated messages and optional SDK dependencies inside the adapter. */ +export async function serializeSdkPolicy(policy: unknown, signal?: AbortSignal): Promise { + try { + signal?.throwIfAborted(); + const sdkPackage = "@nvidia/openshell-sdk/raw"; + const protobufPackage = "@bufbuild/protobuf"; + const [{ SandboxPolicySchema }, { isMessage, toBinary, toJson }] = await Promise.all([ + import(sdkPackage), + import(protobufPackage), + ]); + signal?.throwIfAborted(); + if (!isMessage(policy, SandboxPolicySchema)) throw new OpenShellReadError("schema"); + if (toBinary(SandboxPolicySchema, policy).byteLength > MAX_POLICY_BYTES) { + throw new OpenShellReadError("schema"); + } + rejectUnknownWireFields(policy); + const document = YAML.stringify( + sortCanonicalMappings( + sdkPolicyDocument(toJson(SandboxPolicySchema, policy, { useProtoFieldName: true })), + ), + ); + if ( + Buffer.byteLength(document, "utf8") > MAX_POLICY_BYTES || + !isSandboxPolicyCredentialFree(document) + ) { + throw new OpenShellReadError("schema"); + } + return document; + } catch { + throw new OpenShellReadError(signal?.aborted ? "timeout" : "schema"); + } +} diff --git a/src/lib/adapters/openshell/sandbox-identity-cli.ts b/src/lib/adapters/openshell/sandbox-identity-cli.ts index a560cbb7d8a..5031d80306e 100644 --- a/src/lib/adapters/openshell/sandbox-identity-cli.ts +++ b/src/lib/adapters/openshell/sandbox-identity-cli.ts @@ -15,7 +15,7 @@ import { import { PolicyObservationError } from "./policy-state"; import { openshellNotFoundDiagnosticLines } from "./command-argv"; import { captureSanitizedResolvedOpenshell } from "./sanitized-capture"; -import type { OpenShellSandboxError, OpenShellSandboxResult } from "./sandbox-observer"; +import type { OpenShellSandboxResult } from "./sandbox-observer"; import { classifyCliOpenShellCommandError, type CapturedOpenShellCommandResult, diff --git a/src/lib/adapters/openshell/sandbox-observer-cli.ts b/src/lib/adapters/openshell/sandbox-observer-cli.ts index 804cd4be1fe..a5fb4529555 100644 --- a/src/lib/adapters/openshell/sandbox-observer-cli.ts +++ b/src/lib/adapters/openshell/sandbox-observer-cli.ts @@ -252,7 +252,7 @@ function failure(error: OpenShellSandboxError): OpenShellSandboxResult { } function streamText(value: string | Buffer | null | undefined): string { - return value == null ? "" : String(value); + return String(value ?? ""); } /** Normalize structured runner results inside the CLI implementation. */ diff --git a/src/lib/adapters/openshell/sandbox-policy-cli.ts b/src/lib/adapters/openshell/sandbox-policy-cli.ts index 29f19ebd814..68c058599c9 100644 --- a/src/lib/adapters/openshell/sandbox-policy-cli.ts +++ b/src/lib/adapters/openshell/sandbox-policy-cli.ts @@ -35,7 +35,6 @@ import type { OpenShellSandboxPolicyRead, OpenShellSandboxPolicyReader, OpenShellSandboxPolicyRevisionRead, - OpenShellSandboxPolicySetOutcome, OpenShellSandboxPolicySetSubmission, OpenShellSandboxPolicyWriter, ReadOpenShellSandboxPolicyRequest, @@ -306,18 +305,12 @@ export function createSyncCliOpenShellSandboxPolicyReader( return { readSandboxPolicy: (request) => parsePolicyRead( - deps.capture( - policyReadArgs(request), - captureOptions(request, deps.defaultTimeoutMs), - ), + deps.capture(policyReadArgs(request), captureOptions(request, deps.defaultTimeoutMs)), ), inspectSandboxPolicy: (request) => parsePolicyInspection( request, - deps.capture( - policyInspectionArgs(request), - captureOptions(request, deps.defaultTimeoutMs), - ), + deps.capture(policyInspectionArgs(request), captureOptions(request, deps.defaultTimeoutMs)), ), readSandboxPolicyRevision: (request) => !Number.isSafeInteger(request.revision) || request.revision < 1 @@ -339,10 +332,7 @@ export function createCliOpenShellSandboxPolicyWriter( setSandboxPolicy: async (request) => { assertPolicyRequest(request); return parsePolicySet( - await deps.capture( - policySetArgs(request), - captureOptions(request, deps.defaultTimeoutMs), - ), + await deps.capture(policySetArgs(request), captureOptions(request, deps.defaultTimeoutMs)), ); }, }; @@ -355,10 +345,7 @@ export function createSyncCliOpenShellSandboxPolicyWriter( setSandboxPolicy: (request) => { assertPolicyRequest(request); return parsePolicySet( - deps.capture( - policySetArgs(request), - captureOptions(request, deps.defaultTimeoutMs), - ), + deps.capture(policySetArgs(request), captureOptions(request, deps.defaultTimeoutMs)), ); }, }; diff --git a/src/lib/adapters/openshell/sandbox-policy.ts b/src/lib/adapters/openshell/sandbox-policy.ts index 48e09aeb0aa..085ea55f3ac 100644 --- a/src/lib/adapters/openshell/sandbox-policy.ts +++ b/src/lib/adapters/openshell/sandbox-policy.ts @@ -6,7 +6,6 @@ import type { OpenShellRuntimeSelection } from "./runtime-selection"; import type { OpenShellPolicyInspection, OpenShellSandboxPolicyRead, - OpenShellSandboxPolicySetOutcome, OpenShellSandboxPolicySetSubmission, } from "./policy-boundary"; diff --git a/src/lib/adapters/openshell/sandbox-presence.ts b/src/lib/adapters/openshell/sandbox-presence.ts index fb0f85096e4..def1d801f38 100644 --- a/src/lib/adapters/openshell/sandbox-presence.ts +++ b/src/lib/adapters/openshell/sandbox-presence.ts @@ -36,6 +36,6 @@ export function observeOpenShellSandboxIdentity( const matches = rows.filter((row) => row.name === sandboxName); if (matches.length === 0) return { kind: "absent" }; if (matches.length !== 1) return { kind: "unknown" }; - const match = matches[0]!; + const match = matches[0]; return { kind: "present", id: match.id, phase: match.phase }; } diff --git a/src/lib/adapters/openshell/sdk-read-schema.ts b/src/lib/adapters/openshell/sdk-read-schema.ts index eac7f8edc40..cc06955afce 100644 --- a/src/lib/adapters/openshell/sdk-read-schema.ts +++ b/src/lib/adapters/openshell/sdk-read-schema.ts @@ -60,6 +60,7 @@ export const SandboxResponseSchema = Type.Object({ }), }); export const SandboxConfigResponseSchema = Type.Object({ + policy: Type.Unknown(), workspace: WorkspaceSchema, version: IntegerSchema, policyHash: ReadTextSchema, @@ -68,3 +69,59 @@ export const SandboxConfigResponseSchema = Type.Object({ policySource: Type.Union([Type.Literal(1), Type.Literal(2)]), globalPolicyVersion: IntegerSchema, }); + +// ProtoJSON omits implicit defaults. Validate the fields used by policy conversion; +// other released fields pass through to the existing complete policy validator. +const PolicyUint32Schema = Type.Integer({ minimum: 0, maximum: 4294967295 }); +const PolicyStringMatcherJsonSchema = Type.Object({ + glob: Type.Optional(Type.String()), + any: Type.Optional(Type.Array(Type.String())), +}); +const PolicyMatcherMapJsonSchema = Type.Record(Type.String(), PolicyStringMatcherJsonSchema); +const PolicyMatcherJsonSchema = Type.Object({ + method: Type.Optional(Type.String()), + query: Type.Optional(PolicyMatcherMapJsonSchema), + params: Type.Optional(PolicyMatcherMapJsonSchema), +}); +const PolicyEndpointJsonSchema = Type.Object({ + host: Type.Optional(Type.String()), + port: Type.Optional(PolicyUint32Schema), + ports: Type.Optional(Type.Array(PolicyUint32Schema)), + protocol: Type.Optional(Type.String()), + json_rpc_max_body_bytes: Type.Optional(PolicyUint32Schema), + mcp: Type.Optional( + Type.Object({ + strict_tool_names: Type.Optional(Type.Boolean()), + allow_all_known_mcp_methods: Type.Optional(Type.Boolean()), + }), + ), + rules: Type.Optional(Type.Array(Type.Object({ allow: Type.Optional(PolicyMatcherJsonSchema) }))), + deny_rules: Type.Optional(Type.Array(PolicyMatcherJsonSchema)), +}); +export const PolicyJsonSchema = Type.Object({ + version: Type.Optional(PolicyUint32Schema), + filesystem: Type.Optional( + Type.Object({ + include_workdir: Type.Optional(Type.Boolean()), + read_only: Type.Optional(Type.Array(Type.String())), + read_write: Type.Optional(Type.Array(Type.String())), + }), + ), + process: Type.Optional( + Type.Object({ + run_as_user: Type.Optional(Type.String()), + run_as_group: Type.Optional(Type.String()), + }), + ), + network_policies: Type.Optional( + Type.Record( + Type.String(), + Type.Object({ + endpoints: Type.Optional(Type.Array(PolicyEndpointJsonSchema)), + binaries: Type.Optional(Type.Array(Type.Object({ path: Type.Optional(Type.String()) }))), + }), + ), + ), +}); +export type PolicyMatcherJson = TypeBoxModule.Type.Static; +export type PolicyEndpointJson = TypeBoxModule.Type.Static; diff --git a/src/lib/adapters/openshell/sdk-read.ts b/src/lib/adapters/openshell/sdk-read.ts index e09c567207c..38943b28e4f 100644 --- a/src/lib/adapters/openshell/sdk-read.ts +++ b/src/lib/adapters/openshell/sdk-read.ts @@ -3,7 +3,8 @@ import type * as TypeBoxModule from "typebox" with { "resolution-mode": "import" }; import type * as TypeBoxValueModule from "typebox/value" with { "resolution-mode": "import" }; -import { MetadataSchema, ReadTextSchema, WorkspaceSchema } from "./sdk-read-schema"; +import type { MetadataSchema } from "./sdk-read-schema"; +import { ReadTextSchema, WorkspaceSchema } from "./sdk-read-schema"; import { cloneAndDeepFreeze } from "../../core/immutable"; import { connectManagedOpenShellSdk } from "./sdk"; import type { OpenShellGatewayTarget, OpenShellSandboxError } from "./sandbox-observer"; diff --git a/src/lib/adapters/podman/index.test.ts b/src/lib/adapters/podman/index.test.ts index b451d15065c..03570c82a80 100644 --- a/src/lib/adapters/podman/index.test.ts +++ b/src/lib/adapters/podman/index.test.ts @@ -841,7 +841,6 @@ describe("Podman container engine command adapter", () => { expect(secondCapture).not.toHaveBeenCalled(); }); - it("latches executable authority failure even when socket failure wins the first guard", () => { const socketChanged = new Error("socket changed"); const defaultDeps = executableAuthorityDeps(); diff --git a/src/lib/adapters/podman/index.ts b/src/lib/adapters/podman/index.ts index 98b5112a602..992fba5951c 100644 --- a/src/lib/adapters/podman/index.ts +++ b/src/lib/adapters/podman/index.ts @@ -307,6 +307,12 @@ export function createPodmanContainerEngine( executableProof.assertMetadataAuthority(); } }; + let allowedEnvironmentNames: string[] = []; + if (options.operation === "host-local-inference") { + allowedEnvironmentNames = ["NGC_API_KEY", "NIM_NGC_API_KEY", "OLLAMA_CONTEXT_LENGTH"]; + } else if (options.operation === "managed-bootstrap") { + allowedEnvironmentNames = ["CONTAINERS_CONF", "CONTAINERS_STORAGE_CONF"]; + } const engine = createContainerEngineCommand({ operation: options.operation, engineId: "podman", @@ -315,12 +321,7 @@ export function createPodmanContainerEngine( endpointAuthorityId, executable, endpointArgs: ["--url", `unix://${options.socketAuthority.socketPath}`], - allowedEnvironmentNames: - options.operation === "host-local-inference" - ? ["NGC_API_KEY", "NIM_NGC_API_KEY", "OLLAMA_CONTEXT_LENGTH"] - : options.operation === "managed-bootstrap" - ? ["CONTAINERS_CONF", "CONTAINERS_STORAGE_CONF"] - : [], + allowedEnvironmentNames, commandEnvironment: options.commandEnvironment, capture: options.capture, guard: (phase) => { diff --git a/src/lib/adapters/sandbox/command-transport.test.ts b/src/lib/adapters/sandbox/command-transport.test.ts index 96bbc45c72d..3d1cc251a3c 100644 --- a/src/lib/adapters/sandbox/command-transport.test.ts +++ b/src/lib/adapters/sandbox/command-transport.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync } from "node:child_process"; +import type { spawnSync } from "node:child_process"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenShellSandboxBufferedCommandExecutor } from "../openshell/sandbox-command"; diff --git a/test/automation/lint/adapter-formatting.test.ts b/test/automation/lint/adapter-formatting.test.ts new file mode 100644 index 00000000000..72821c825c0 --- /dev/null +++ b/test/automation/lint/adapter-formatting.test.ts @@ -0,0 +1,94 @@ +// 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 { expect, it } from "vitest"; + +it.each([ + { + name: "unchanged adapters", + file: "src/lib/adapters/read.ts", + tracked: true, + explicit: false, + expected: 1, + }, + { + name: "explicit existing adapters", + file: "src/lib/adapters/read.ts", + tracked: true, + explicit: true, + expected: 1, + }, + { + name: "new files outside adapters", + file: "src/lib/new.ts", + tracked: false, + explicit: false, + expected: 1, + }, + { + name: "existing files outside adapters", + file: "src/lib/legacy.ts", + tracked: true, + explicit: true, + expected: 0, + }, +])("enforces formatting for $name", ({ file, tracked, explicit, expected }) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-adapter-format-")); + try { + fs.symlinkSync(path.resolve("node_modules"), path.join(root, "node_modules"), "dir"); + fs.mkdirSync(path.join(root, "tools/lint"), { recursive: true }); + fs.copyFileSync("oxfmt.config.ts", path.join(root, "oxfmt.config.ts")); + fs.copyFileSync("oxc.ignore-patterns.ts", path.join(root, "oxc.ignore-patterns.ts")); + fs.copyFileSync( + "tools/lint/format-added-files.sh", + path.join(root, "tools/lint/format-added-files.sh"), + ); + const git = (...args: string[]) => { + const result = spawnSync("git", args, { cwd: root, encoding: "utf8" }); + expect(result.status, result.stderr).toBe(0); + }; + git("init", "--quiet"); + fs.writeFileSync(path.join(root, ".gitignore"), "node_modules\n"); + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + const unformatted = "export const value={a:1,b:'two'}\n"; + fs.writeFileSync(path.join(root, file), unformatted); + git("add", "--", tracked ? file : ".gitignore"); + git( + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "-c", + "commit.gpgsign=false", + "-c", + "core.hooksPath=/dev/null", + "commit", + "--quiet", + "--allow-empty", + "-m", + "fixture", + ); + const run = (mode: string) => + spawnSync("bash", ["tools/lint/format-added-files.sh", mode, ...(explicit ? [file] : [])], { + cwd: root, + encoding: "utf8", + env: { ...process.env, NEMOCLAW_FORMAT_BASE_REF: "HEAD" }, + }); + const check = run("--check"); + expect(check.status, check.stderr).toBe(expected); + expect(fs.readFileSync(path.join(root, file), "utf8")).toBe(unformatted); + const write = run("--write"); + expect(write.status, write.stderr).toBe(0); + expect(fs.readFileSync(path.join(root, file), "utf8")).toBe( + expected ? 'export const value = { a: 1, b: "two" };\n' : unformatted, + ); + const formatted = run("--check"); + expect(formatted.status, formatted.stderr).toBe(0); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/test/automation/lint/adapters.test.ts b/test/automation/lint/adapters.test.ts new file mode 100644 index 00000000000..276b36b05e6 --- /dev/null +++ b/test/automation/lint/adapters.test.ts @@ -0,0 +1,116 @@ +// 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 { expect, it } from "vitest"; + +it.each([ + { + name: "unused declarations", + source: "export function read() { const unused = 1; }", + rule: "eslint(no-unused-vars)", + }, + { + name: "explicit any", + source: "export type Result = any;", + rule: "typescript(no-explicit-any)", + }, + { + name: "value imports used only as types", + source: 'import { Stats } from "node:fs"; export type Result = Stats;', + rule: "typescript(consistent-type-imports)", + fixes: true, + }, + { + name: "value exports used only as types", + source: "type Result = string; export { Result };", + rule: "typescript(consistent-type-exports)", + typed: true, + }, + { + name: "loose equality", + source: "export function read(value: string) { return value == 'a'; }", + rule: "eslint(eqeqeq)", + }, + { + name: "debugger statements", + source: "export function read() { debugger; }", + rule: "eslint(no-debugger)", + }, + { + name: "production non-null assertions", + source: "export function read(values: string[]) { return values.at(0)!; }", + rule: "typescript(no-non-null-assertion)", + }, + { + name: "production nested ternaries", + source: "export function read(a: boolean, b: boolean) { return a ? 1 : b ? 2 : 3; }", + rule: "eslint(no-nested-ternary)", + }, + { + name: "floating promises", + source: "export function read() { Promise.resolve(); }", + rule: "typescript(no-floating-promises)", + typed: true, + }, + { + name: "promise conditions", + source: "export function read() { if (Promise.resolve(false)) return 1; return 0; }", + rule: "typescript(no-misused-promises)", + typed: true, + }, + { + name: "await on synchronous values", + source: "export async function read() { return await 1; }", + rule: "typescript(await-thenable)", + typed: true, + }, + { + name: "missing union cases", + source: 'export function read(kind: "one" | "two") { switch (kind) { case "one": return 1; } }', + rule: "typescript(switch-exhaustiveness-check)", + typed: true, + }, + { + name: "handled promises", + source: "export async function read() { return await Promise.resolve(1); }", + rule: "", + typed: true, + }, + { + name: "test assertion allowance", + source: "export function read(values: string[]) { return values.at(0)!; }", + rule: "", + test: true, + }, +])("checks adapter $name through the commit hook", ({ source, rule, typed, test, fixes }) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-adapter-lint-")); + try { + const file = `src/lib/adapters/example/read${test ? ".test" : ""}.ts`; + fs.symlinkSync(path.resolve("node_modules"), path.join(root, "node_modules"), "dir"); + fs.copyFileSync("oxlint.config.ts", path.join(root, "oxlint.config.ts")); + fs.copyFileSync("oxlint.type-aware.config.ts", path.join(root, "oxlint.type-aware.config.ts")); + fs.copyFileSync("oxc.ignore-patterns.ts", path.join(root, "oxc.ignore-patterns.ts")); + fs.copyFileSync("tsconfig.cli.json", path.join(root, "tsconfig.cli.json")); + fs.copyFileSync(".pre-commit-config.yaml", path.join(root, ".pre-commit-config.yaml")); + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), source); + const init = spawnSync("git", ["init", "--quiet"], { cwd: root, encoding: "utf8" }); + expect(init.status, init.stderr).toBe(0); + const add = spawnSync("git", ["add", "--", file], { cwd: root, encoding: "utf8" }); + expect(add.status, add.stderr).toBe(0); + const result = spawnSync( + path.resolve("node_modules/.bin/prek"), + ["run", typed ? "oxlint-adapters-type-aware" : "oxlint-fix", "--files", file], + { cwd: root, encoding: "utf8" }, + ); + expect(result.status, result.stdout + result.stderr).toBe(rule ? 1 : 0); + expect(fs.readFileSync(path.join(root, file), "utf8") !== source).toBe(fixes ?? false); + expect(result.stdout + result.stderr).toContain(rule && !fixes ? rule : ""); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/test/automation/lint/config-export-complexity.test.ts b/test/automation/lint/config-export-complexity.test.ts index 0cd2ed63c57..1ac2e250dcf 100644 --- a/test/automation/lint/config-export-complexity.test.ts +++ b/test/automation/lint/config-export-complexity.test.ts @@ -7,7 +7,7 @@ import os from "node:os"; import path from "node:path"; import { expect, it } from "vitest"; -const branches = `function read(flags: boolean[]) { return ${Array.from({ length: 12 }, (_, index) => `flags[${index}]`).join(" || ")}; }`; +const branches = `export function read(flags: boolean[]) { return ${Array.from({ length: 12 }, (_, index) => `flags[${index}]`).join(" || ")}; }`; const nesting = `function verify(value: boolean) { ${"if (value) { ".repeat(6)}return true;${" }".repeat(6)} return false; }`; it.each([ @@ -32,7 +32,7 @@ it.each([ { name: "nested ternary rejection in SDK reads", file: "src/lib/adapters/openshell/sdk-read.ts", - source: "function classify(a: boolean, b: boolean) { return a ? 1 : b ? 2 : 3; }", + source: "export function classify(a: boolean, b: boolean) { return a ? 1 : b ? 2 : 3; }", rules: ["eslint(no-nested-ternary)"], }, { diff --git a/test/e2e/README.md b/test/e2e/README.md index aebc4f47b0f..5607f8bcb37 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -364,7 +364,8 @@ After ordinary restricted OpenClaw onboarding, it invokes the candidate `config It compares the exported sandbox name, immutable managed image, hosted endpoint, and explicit policy with the fixture's registered and effective state. It then changes the fixture's recorded sandbox fingerprint and requires export to fail without creating a file. The fixture restores the registry in `finally` and removes private export files through its existing cleanup registry. -This covers the SDK connection and complete export observation boundary; the deterministic adapter tests remain the owners of individual wire shapes and malformed responses. +The exported effective policy comes from the SDK configuration response and is compared with the +independent CLI policy observation. This covers the SDK connection and complete export observation boundary; the deterministic adapter tests remain the owners of individual wire shapes and malformed responses. The assertion budget is unchanged. Nine export assertions replace nine redundant checks in the same target: - Two CLI-file and two OpenShell-version checks are covered by the retained successful onboarding checks. diff --git a/test/helpers/vitest-watch-triggers.ts b/test/helpers/vitest-watch-triggers.ts index 50337c90a60..ce4fb3b0112 100644 --- a/test/helpers/vitest-watch-triggers.ts +++ b/test/helpers/vitest-watch-triggers.ts @@ -45,8 +45,17 @@ function runTests(...tests: string[]): () => string[] { export const vitestWatchTriggerPatterns: VitestWatchTriggerPattern[] = [ { - pattern: /(?:^|\/)(?:oxlint\.config\.ts|oxc\.ignore-patterns\.ts)$/, - testsToRun: runTests("test/automation/lint/config-export-complexity.test.ts"), + pattern: + /(?:^|\/)(?:oxlint(?:\.type-aware)?\.config\.ts|oxc\.ignore-patterns\.ts|tsconfig\.cli\.json|\.pre-commit-config\.yaml)$/, + testsToRun: runTests( + "test/automation/lint/config-export-complexity.test.ts", + "test/automation/lint/adapters.test.ts", + ), + }, + { + pattern: + /(?:^|\/)(?:tools\/lint\/format-added-files\.sh|oxfmt\.config\.ts|oxc\.ignore-patterns\.ts)$/, + testsToRun: runTests("test/automation/lint/adapter-formatting.test.ts"), }, { pattern: /(?:^|\/)scripts\/lib\/sandbox-init\.sh$/, diff --git a/test/onboarding/openshell-sdk-export-reads.test.ts b/test/onboarding/openshell-sdk-export-reads.test.ts index 4a12e443006..90ff652f587 100644 --- a/test/onboarding/openshell-sdk-export-reads.test.ts +++ b/test/onboarding/openshell-sdk-export-reads.test.ts @@ -1,10 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; +import { spawnSync } from "node:child_process"; +import { inspect } from "node:util"; +import { describe, expect, it, vi } from "vitest"; +import YAML from "yaml"; import { createProviders } from "../../src/lib/adapters/openshell/providers"; import { createSandboxes } from "../../src/lib/adapters/openshell/sandboxes"; -import { createSandboxConfig } from "../../src/lib/adapters/openshell/sandbox-config"; +import { + createSandboxConfig, + serializeSdkPolicy, +} from "../../src/lib/adapters/openshell/sandbox-config"; import type { OpenShellReadClient } from "../../src/lib/adapters/openshell/sdk-read"; // CI stages the reviewed optional SDK artifact. Source-only checkouts can run @@ -68,6 +74,17 @@ describe("released OpenShell SDK export reads", () => { }), getSandboxConfig: async () => roundTrip(raw.GetSandboxConfigResponseSchema, { + policy: { + version: 1, + filesystem: { readOnly: ["/usr"] }, + networkPolicies: { + api: { + name: "api", + endpoints: [{ host: "api.example", ports: [443] }], + binaries: [{ path: "/usr/bin/curl" }], + }, + }, + }, workspace: "default", version: 3, policyHash: "a".repeat(64), @@ -98,13 +115,420 @@ describe("released OpenShell SDK export reads", () => { policyVersion: 3, providers: [], }); - expect( - await createSandboxConfig(connect).get({ ...request, sandboxId: "resource-id" }), - ).toMatchObject({ + const config = await createSandboxConfig(connect).get({ + ...request, + sandboxId: "resource-id", + }); + expect(config).toMatchObject({ policySource: "sandbox", globalPolicyVersion: 0, configRevision: "9007199254740993", providerEnvRevision: "18446744073709551615", + policy: { appliedRevision: 3 }, + }); + expect(YAML.parse(config.policy.document)).toEqual({ + version: 1, + filesystem_policy: { include_workdir: false, read_only: ["/usr"] }, + network_policies: { + api: { + name: "api", + endpoints: [{ host: "api.example", port: 443 }], + binaries: [{ path: "/usr/bin/curl" }], + }, + }, + }); + }, + ); +}); + +describe.skipIf(!hasSdkArtifact())("released OpenShell policy wire safety", () => { + it("rejects unknown policy wire fields instead of exporting a partial policy", async () => { + const sdkPackage = "@nvidia/openshell-sdk/raw"; + const protobufPackage = "@bufbuild/protobuf"; + const [{ SandboxPolicySchema }, { create, toBinary, fromBinary }] = await Promise.all([ + import(sdkPackage), + import(protobufPackage), + ]); + const bytes = toBinary(SandboxPolicySchema, create(SandboxPolicySchema, { version: 1 })); + const policy = fromBinary(SandboxPolicySchema, Uint8Array.from([...bytes, 0xf8, 0x07, 0x01])); + await expect(serializeSdkPolicy(policy)).rejects.toMatchObject({ + kind: "schema", + message: "OpenShell read failed (schema).", + }); + }); + + it("rejects an oversized encoded policy before YAML serialization", async () => { + const sdkPackage = "@nvidia/openshell-sdk/raw"; + const protobufPackage = "@bufbuild/protobuf"; + const [{ SandboxPolicySchema }, { create }] = await Promise.all([ + import(sdkPackage), + import(protobufPackage), + ]); + const policy = create(SandboxPolicySchema, { + version: 1, + filesystem: { readOnly: ["/" + "界".repeat(350000)] }, + }); + const stringify = vi.spyOn(YAML, "stringify"); + try { + await expect(serializeSdkPolicy(policy)).rejects.toMatchObject({ kind: "schema" }); + expect(stringify).not.toHaveBeenCalled(); + } finally { + stringify.mockRestore(); + } + }); + + it("rejects an aborted policy read before loading the SDK", async () => { + await expect(serializeSdkPolicy(undefined, AbortSignal.abort())).rejects.toMatchObject({ + kind: "timeout", + }); + }); + + it("stops conversion when the read aborts during SDK loading", async () => { + const controller = new AbortController(); + const stringify = vi.spyOn(YAML, "stringify"); + try { + const result = serializeSdkPolicy(undefined, controller.signal); + controller.abort(); + await expect(result).rejects.toMatchObject({ kind: "timeout" }); + expect(stringify).not.toHaveBeenCalled(); + } finally { + stringify.mockRestore(); + } + }); + + it.runIf(process.platform !== "win32")("rejects deep MCP paths within a bounded heap", () => { + const source = ` + (async () => { + const [{ create }, { SandboxPolicySchema }] = await Promise.all([ + import("@bufbuild/protobuf"), import("@nvidia/openshell-sdk/raw"), + ]); + const { serializeSdkPolicy } = require("./src/lib/adapters/openshell/sandbox-config.ts"); + const params = { ["a.".repeat(16000) + "leaf"]: { glob: "safe" } }; + const policy = create(SandboxPolicySchema, { + version: 1, + networkPolicies: { api: { + name: "api", endpoints: [{ host: "api.example", port: 443, protocol: "mcp", rules: [{ allow: { params } }] }], + } }, + }); + await serializeSdkPolicy(policy).then( + () => process.exit(2), + (error) => { process.stdout.write(error.kind); process.exit(error.kind === "schema" ? 0 : 2); }, + ); + })(); + `; + const result = spawnSync( + "bash", + [ + "-c", + 'ulimit -c 0; exec "$@"', + "bounded-policy", + process.execPath, + "--max-old-space-size=256", + "--import", + "tsx", + "--eval", + source, + ], + { + cwd: process.cwd(), + encoding: "utf8", + timeout: 10_000, + env: { PATH: process.env.PATH }, + }, + ); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe("schema"); + }); + + it("rejects YAML expansion beyond the policy read limit", async () => { + const sdkPackage = "@nvidia/openshell-sdk/raw"; + const protobufPackage = "@bufbuild/protobuf"; + const [{ SandboxPolicySchema }, { create, toBinary }] = await Promise.all([ + import(sdkPackage), + import(protobufPackage), + ]); + const policy = create(SandboxPolicySchema, { + version: 1, + filesystem: { readOnly: ["/" + "\x01".repeat(300000)] }, + }); + expect(toBinary(SandboxPolicySchema, policy).byteLength).toBeLessThan(1024 * 1024); + const outcome = await serializeSdkPolicy(policy).then( + () => "accepted", + (error) => error.kind, + ); + expect(outcome).toBe("schema"); + }); + + it("serializes equal policy maps identically regardless of wire order", async () => { + const sdkPackage = "@nvidia/openshell-sdk/raw"; + const protobufPackage = "@bufbuild/protobuf"; + const [{ SandboxPolicySchema }, { create }] = await Promise.all([ + import(sdkPackage), + import(protobufPackage), + ]); + const rule = { + name: "api", + endpoints: [{ host: "api.example", port: 443 }], + binaries: [{ path: "/usr/bin/curl" }], + }; + const first = create(SandboxPolicySchema, { + version: 1, + networkPolicies: { z: rule, a: rule }, + }); + const second = create(SandboxPolicySchema, { + version: 1, + networkPolicies: { a: rule, z: rule }, + }); + expect(await serializeSdkPolicy(first)).toBe(await serializeSdkPolicy(second)); + }); + + it("rejects unknown nested policy wire fields", async () => { + const sdkPackage = "@nvidia/openshell-sdk/raw"; + const protobufPackage = "@bufbuild/protobuf"; + const [{ SandboxPolicySchema }, { create, toBinary, fromBinary }] = await Promise.all([ + import(sdkPackage), + import(protobufPackage), + ]); + const input = create(SandboxPolicySchema, { + version: 1, + networkPolicies: { + api: { + name: "api", + endpoints: [{ host: "api.example", port: 443 }], + binaries: [{ path: "/usr/bin/curl" }], + }, + }, + }); + input.networkPolicies.api.endpoints[0].$unknown = [ + { no: 127, wireType: 0, data: Uint8Array.of(1) }, + ]; + const policy = fromBinary(SandboxPolicySchema, toBinary(SandboxPolicySchema, input)); + await expect(serializeSdkPolicy(policy)).rejects.toMatchObject({ + kind: "schema", + message: "OpenShell read failed (schema).", + }); + }); + + it.each([undefined, {}])("rejects missing or untyped policy messages: %j", async (policy) => { + await expect(serializeSdkPolicy(policy)).rejects.toMatchObject({ + kind: "schema", + message: "OpenShell read failed (schema).", + }); + }); + + it("rejects credential-bearing policy matchers without exposing their values", async () => { + const sdkPackage = "@nvidia/openshell-sdk/raw"; + const protobufPackage = "@bufbuild/protobuf"; + const [{ SandboxPolicySchema }, { create }] = await Promise.all([ + import(sdkPackage), + import(protobufPackage), + ]); + const policy = create(SandboxPolicySchema, { + version: 1, + networkPolicies: { + api: { + name: "api", + endpoints: [ + { + host: "api.example", + port: 443, + rules: [{ allow: { query: { api_key: { glob: "credential-canary" } } } }], + }, + ], + binaries: [{ path: "/usr/bin/curl" }], + }, + }, + }); + const error = await serializeSdkPolicy(policy).catch((reason: unknown) => reason); + expect(error).toMatchObject({ + kind: "schema", + message: "OpenShell read failed (schema).", + }); + expect(inspect(error, { depth: null })).not.toContain("credential-canary"); + }); +}); + +describe.skipIf(!hasSdkArtifact())("released OpenShell policy document conversion", () => { + it.each([ + ["single port", { port: 443 }, { port: 443 }], + ["single port list", { ports: [443] }, { port: 443 }], + ["multiple ports", { port: 80, ports: [443, 8443] }, { ports: [443, 8443] }], + [ + "REST allow and deny query matchers", + { + port: 443, + protocol: "rest", + rules: [ + { + allow: { + method: "GET", + path: "/v1/*", + query: { repo: { glob: "NVIDIA/*" }, scope: { any: ["read", "list"] } }, + }, + }, + ], + denyRules: [{ method: "DELETE", path: "/v1/*", query: { scope: { glob: "admin" } } }], + }, + { + port: 443, + protocol: "rest", + rules: [ + { + allow: { + method: "GET", + path: "/v1/*", + query: { repo: "NVIDIA/*", scope: { any: ["read", "list"] } }, + }, + }, + ], + deny_rules: [{ method: "DELETE", path: "/v1/*", query: { scope: "admin" } }], + }, + ], + [ + "JSON-RPC body limit and flat parameters", + { + port: 443, + protocol: "json-rpc", + jsonRpcMaxBodyBytes: 4096, + rules: [ + { + allow: { + method: "read", + params: { + name: { glob: "x" }, + "a.b": { glob: "y" }, + }, + }, + }, + ], + }, + { + port: 443, + protocol: "json-rpc", + json_rpc: { max_body_bytes: 4096 }, + rules: [{ allow: { method: "read", params: { name: "x", "a.b": "y" } } }], + }, + ], + [ + "MCP false options, tool selection, and nested parameters", + { + port: 443, + protocol: "mcp", + jsonRpcMaxBodyBytes: 4096, + mcp: { strictToolNames: false, allowAllKnownMcpMethods: false }, + rules: [ + { + allow: { + method: "tools/call", + params: { + name: { glob: "search" }, + "arguments.repo": { glob: "NVIDIA/*" }, + "arguments.limit": { any: ["1", "2"] }, + }, + }, + }, + ], + }, + { + port: 443, + protocol: "mcp", + mcp: { max_body_bytes: 4096, strict_tool_names: false, allow_all_known_mcp_methods: false }, + rules: [ + { + allow: { + method: "tools/call", + tool: "search", + params: { arguments: { repo: "NVIDIA/*", limit: { any: ["1", "2"] } } }, + }, + }, + ], + }, + ], + [ + "MCP method profile and denied tools", + { + port: 443, + protocol: "mcp", + mcp: { strictToolNames: true, allowAllKnownMcpMethods: true }, + rules: [ + { allow: { method: "tools/call", params: { name: { glob: "search" } } } }, + { allow: { method: "*" } }, + ], + denyRules: [{ method: "tools/call", params: { name: { any: ["delete", "write"] } } }], + }, + { + port: 443, + protocol: "mcp", + mcp: { strict_tool_names: true, allow_all_known_mcp_methods: true }, + rules: [{ allow: { tool: "search" } }, { allow: {} }], + deny_rules: [{ tool: { any: ["delete", "write"] } }], + }, + ], + [ + "colliding MCP parameter paths", + { + port: 443, + protocol: "mcp", + rules: [{ allow: { params: { a: { glob: "x" }, "a.b": { glob: "y" } } } }], + }, + { + port: 443, + protocol: "mcp", + rules: [{ allow: { params: { a: "x", "a.b": "y" } } }], + }, + ], + ])( + "preserves %s through SDK binary and JSON serialization", + async (_case, endpoint, expected) => { + const sdkPackage = "@nvidia/openshell-sdk/raw"; + const protobufPackage = "@bufbuild/protobuf"; + const [{ SandboxPolicySchema }, { create, toBinary, fromBinary }] = await Promise.all([ + import(sdkPackage), + import(protobufPackage), + ]); + const input = create(SandboxPolicySchema, { + version: 1, + networkPolicies: { + api: { + name: "api", + endpoints: [{ host: "api.example", ...endpoint }], + binaries: [{ path: "/usr/bin/curl", harness: true }], + }, + }, + }); + const policy = fromBinary(SandboxPolicySchema, toBinary(SandboxPolicySchema, input)); + const reader = createSandboxConfig(async () => ({ + raw: { + getProvider: async () => undefined, + getProviderProfile: async () => undefined, + getSandbox: async () => undefined, + getSandboxConfig: async () => ({ + policy, + workspace: "default", + version: 3, + policyHash: "a".repeat(64), + policySource: 1, + configRevision: 3n, + providerEnvRevision: 1n, + globalPolicyVersion: 0, + }), + }, + })); + const config = await reader.get({ + sandboxId: "verified-id", + target: { kind: "named", gatewayName: "nemoclaw" }, + workspace: "default", + signal: new AbortController().signal, + }); + expect(YAML.parse(config.policy.document)).toEqual({ + version: 1, + network_policies: { + api: { + name: "api", + endpoints: [{ host: "api.example", ...expected }], + binaries: [{ path: "/usr/bin/curl" }], + }, + }, }); }, ); diff --git a/tools/lint/format-added-files.sh b/tools/lint/format-added-files.sh index 0f20a8369c9..82b6429440b 100755 --- a/tools/lint/format-added-files.sh +++ b/tools/lint/format-added-files.sh @@ -33,10 +33,11 @@ if ((${#candidates[@]} == 0)); then done < <( git diff --name-only --diff-filter=ACMR -z "${base_commit}" -- git ls-files --others --exclude-standard -z + git ls-files -z -- src/lib/adapters ) fi -added_files=() +format_files=() for file in "${candidates[@]}"; do case "${file}" in "" | /* | . | .. | ./* | ../* | */./* | */../* | */. | */..) @@ -55,13 +56,13 @@ for file in "${candidates[@]}"; do *.cjs | *.cts | *.js | *.jsx | *.mjs | *.mts | *.ts | *.tsx) ;; *) continue ;; esac - if [[ "${file}" == .dsh/tools/* ]] || ! git cat-file -e "${base_commit}:${file}" 2>/dev/null; then - added_files+=("${file}") + if [[ "${file}" == .dsh/tools/* || "${file}" == src/lib/adapters/* ]] || ! git cat-file -e "${base_commit}:${file}" 2>/dev/null; then + format_files+=("${file}") fi done -if ((${#added_files[@]} == 0)); then +if ((${#format_files[@]} == 0)); then exit 0 fi -exec npx oxfmt "${mode}" --no-error-on-unmatched-pattern -- "${added_files[@]}" +exec npx oxfmt "${mode}" --no-error-on-unmatched-pattern -- "${format_files[@]}"