Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions agents/hermes/policy-additions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ filesystem_policy:
- /dev/urandom
- /app
- /run/nemoclaw/managed-startup-ca-bundle.pem
- /run/nemoclaw/managed-startup-runtime.env
- /etc
- /var/log
- /var/lib/dpkg # Allow package-version inspection without package mutation.
Expand Down
1 change: 1 addition & 0 deletions agents/hermes/policy-permissive.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ filesystem_policy:
- /dev/urandom
- /app
- /run/nemoclaw/managed-startup-ca-bundle.pem
- /run/nemoclaw/managed-startup-runtime.env
- /etc
- /var/log
- /var/lib/dpkg # Allow package-version inspection without package mutation.
Expand Down
1 change: 1 addition & 0 deletions agents/langchain-deepagents-code/policy-additions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ filesystem_policy:
- /dev/urandom
- /app
- /run/nemoclaw/managed-startup-ca-bundle.pem
- /run/nemoclaw/managed-startup-runtime.env
- /etc
- /var/log
- /var/lib/dpkg # Allow package-version inspection without package mutation.
Expand Down
1 change: 1 addition & 0 deletions agents/openclaw/policy-permissive.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ filesystem_policy:
- /dev/urandom
- /app
- /run/nemoclaw/managed-startup-ca-bundle.pem
- /run/nemoclaw/managed-startup-runtime.env
- /etc
- /var/log
- /var/lib/dpkg # Allow package-version inspection without package mutation.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ filesystem_policy:
- /dev/urandom
- /app
- /run/nemoclaw/managed-startup-ca-bundle.pem
- /run/nemoclaw/managed-startup-runtime.env
- /etc
- /var/log
- /var/lib/dpkg # Allow package-version inspection without package mutation.
Expand Down
1 change: 1 addition & 0 deletions nemoclaw-blueprint/policies/openclaw-sandbox.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ filesystem_policy:
- /dev/urandom
- /app
- /run/nemoclaw/managed-startup-ca-bundle.pem
- /run/nemoclaw/managed-startup-runtime.env
- /etc
- /var/log
- /var/lib/dpkg # Allow package-version inspection without package mutation.
Expand Down
83 changes: 70 additions & 13 deletions src/lib/onboard/initial-policy-real-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,16 @@ import { afterEach, describe, expect, it } from "vitest";
import YAML from "yaml";

import { SHIPPED_MANAGED_IMAGE_AGENTS } from "./managed-image/contract";
import { MANAGED_STARTUP_MERGED_CA_FILE } from "./managed-startup/image-runtime";
import {
MANAGED_STARTUP_COMPLETION_FILE,
MANAGED_STARTUP_MERGED_CA_FILE,
MANAGED_STARTUP_RUNTIME_ENV_FILE,
} from "./managed-startup/image-runtime";
import {
MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_DIRECTORY,
MANAGED_STARTUP_SHARED_ROLLBACK_RECEIPT_DIRECTORY,
MANAGED_STARTUP_SHARED_TRANSACTION_DIRECTORY,
} from "./managed-startup/shared-state-transaction";
import { prepareInitialSandboxCreatePolicy } from "./initial-policy";

type PolicyRule = {
Expand Down Expand Up @@ -99,20 +108,43 @@ describe("initial sandbox policy real preset merge", () => {
const shippingPolicyCases = managedImagePolicyCases.filter(
({ agent }) => agent !== "langchain-deepagents-code",
);

it("covers the complete shipped managed startup CA policy matrix", () => {
const managedStartupReadOnlyPaths = [
{ path: MANAGED_STARTUP_MERGED_CA_FILE, issue: "#9360", purpose: "CA bundle" },
{
path: MANAGED_STARTUP_RUNTIME_ENV_FILE,
issue: "#9357",
purpose: "runtime environment",
},
] as const;
const protectedManagedStartupPaths = [
MANAGED_STARTUP_COMPLETION_FILE,
"/run/nemoclaw/openclaw-config-guard",
MANAGED_STARTUP_SHARED_ROLLBACK_RECEIPT_DIRECTORY,
MANAGED_STARTUP_SHARED_TRANSACTION_DIRECTORY,
MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_DIRECTORY,
] as const;

it("covers the complete shipped managed startup trust policy matrix", () => {
const policyIdentities = managedImagePolicyCases.map(
({ path: policyPath, agent }) => `${agent}:${policyPath.join("/")}`,
);

expect(Object.keys(managedImagePolicyPathsByAgent)).toEqual([...SHIPPED_MANAGED_IMAGE_AGENTS]);
expect(policyIdentities).toHaveLength(6);
expect(new Set(policyIdentities).size).toBe(policyIdentities.length);
expect(managedStartupReadOnlyPaths.map(({ path: trustedPath }) => trustedPath)).toEqual([
MANAGED_STARTUP_MERGED_CA_FILE,
MANAGED_STARTUP_RUNTIME_ENV_FILE,
]);
});

it.each(managedImagePolicyCases)(
"grants $agent policy $path exact read-only access to the managed startup CA bundle (#9360)",
(policyCase) => {
it.each(
managedImagePolicyCases.flatMap((policyCase) =>
managedStartupReadOnlyPaths.map((trustedPath) => ({ policyCase, trustedPath })),
),
)(
"grants $policyCase.agent policy $policyCase.path exact read-only access to the managed startup $trustedPath.purpose ($trustedPath.issue)",
({ policyCase, trustedPath }) => {
const prepared = prepareInitialSandboxCreatePolicy(repoPath(...policyCase.path), [], {
agentName: policyCase.agent,
});
Expand All @@ -121,23 +153,48 @@ describe("initial sandbox policy real preset merge", () => {
const readWrite = policy.filesystem_policy?.read_write ?? [];
const normalizedReadOnly = readOnly.map(normalizeFilesystemPolicyPath);
const normalizedReadWrite = readWrite.map(normalizeFilesystemPolicyPath);
const managedCaAncestors = filesystemPolicyAncestors(MANAGED_STARTUP_MERGED_CA_FILE);
const trustedPathAncestors = filesystemPolicyAncestors(trustedPath.path);

expect(readOnly, policyCase.path.join("/")).toContain(MANAGED_STARTUP_MERGED_CA_FILE);
expect(normalizedReadWrite, policyCase.path.join("/")).not.toContain(
MANAGED_STARTUP_MERGED_CA_FILE,
);
expect(readOnly, policyCase.path.join("/")).toContain(trustedPath.path);
expect(normalizedReadWrite, policyCase.path.join("/")).not.toContain(trustedPath.path);
expect(
normalizedReadOnly.filter((candidate) => managedCaAncestors.includes(candidate)),
normalizedReadOnly.filter((candidate) => trustedPathAncestors.includes(candidate)),
policyCase.path.join("/"),
).toEqual([]);
expect(
normalizedReadWrite.filter((candidate) => managedCaAncestors.includes(candidate)),
normalizedReadWrite.filter((candidate) => trustedPathAncestors.includes(candidate)),
policyCase.path.join("/"),
).toEqual([]);
},
);

it.each(
managedImagePolicyCases.flatMap((policyCase) =>
protectedManagedStartupPaths.map((protectedPath) => ({ policyCase, protectedPath })),
),
)(
"keeps $protectedPath inaccessible in $policyCase.agent policy $policyCase.path (#9357)",
({ policyCase, protectedPath }) => {
const prepared = prepareInitialSandboxCreatePolicy(repoPath(...policyCase.path), [], {
agentName: policyCase.agent,
});
const policy = readPreparedPolicy(prepared);
const grantedPaths = [
...(policy.filesystem_policy?.read_only ?? []),
...(policy.filesystem_policy?.read_write ?? []),
].map(normalizeFilesystemPolicyPath);
const exposingGrants = new Set([
...filesystemPolicyAncestors(protectedPath),
normalizeFilesystemPolicyPath(protectedPath),
]);

expect(
grantedPaths.filter((candidate) => exposingGrants.has(candidate)),
`${policyCase.path.join("/")} exposes ${protectedPath}`,
).toEqual([]);
},
);

it.each([
{
path: ["nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"],
Expand Down
97 changes: 94 additions & 3 deletions src/lib/onboard/managed-startup-image-runtime-handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,31 @@ describe("managed startup image runtime handoff and descriptor integrity", () =>
owned(realLstatSync(file, options))) as typeof fs.lstatSync);
}

function mockRuntimeDescriptorOwnership(
runtimeEnvironmentFile: string,
uid: bigint,
gid: bigint,
): void {
const realFstatSync = fs.fstatSync.bind(fs);
const runtimeInode = fs.lstatSync(runtimeEnvironmentFile, { bigint: true }).ino;
vi.spyOn(fs, "fstatSync").mockImplementation(((descriptor: number, options: { bigint: true }) => {
const stat = realFstatSync(descriptor, options);
const isRuntimeDescriptor = stat.ino === runtimeInode;
const ownership = new Map<PropertyKey, unknown>([
["uid", isRuntimeDescriptor ? uid : 0n],
["gid", isRuntimeDescriptor ? gid : 0n],
]);
return new Proxy(stat, {
get(inner, property) {
const value = ownership.has(property)
? ownership.get(property)
: (Reflect.get(inner, property, inner) as unknown);
return typeof value === "function" ? value.bind(inner) : value;
},
});
}) as typeof fs.fstatSync);
}

function writeCompletionFixture(
profile: ManagedStartupProfile,
corporateCaMerged = false,
Expand Down Expand Up @@ -189,11 +214,16 @@ describe("managed startup image runtime handoff and descriptor integrity", () =>
).toThrow(/completion marker does not match the requested profile/u);
});

it("rejects runtime handoff drift after a matching completion", () => {
it("rejects a replaced runtime handoff after a matching completion", () => {
const fixture = writeCompletionFixture(managedStartupE2eProfile("hermes"));
mockDescriptorOwnership(0n, 0n);
fs.chmodSync(fixture.runtimeEnvironmentFile, 0o644);
fs.appendFileSync(fixture.runtimeEnvironmentFile, "export NEMOCLAW_MODEL='tampered/model'\n");
const originalRuntimeEnvironment = fs.readFileSync(fixture.runtimeEnvironmentFile, "utf8");
fs.renameSync(fixture.runtimeEnvironmentFile, `${fixture.runtimeEnvironmentFile}.original`);
fs.writeFileSync(
fixture.runtimeEnvironmentFile,
`${originalRuntimeEnvironment}export NEMOCLAW_MODEL='tampered/model'\n`,
{ mode: 0o444 },
);
fs.chmodSync(fixture.runtimeEnvironmentFile, 0o444);

expect(() =>
Expand All @@ -206,6 +236,67 @@ describe("managed startup image runtime handoff and descriptor integrity", () =>
).toThrow(/runtime environment digest mismatch/u);
});

it("fails closed when the runtime handoff is missing", () => {
const fixture = writeCompletionFixture(managedStartupE2eProfile("openclaw"));
mockDescriptorOwnership(0n, 0n);
fs.unlinkSync(fixture.runtimeEnvironmentFile);

expect(() =>
verifyManagedStartupImageCompletion(
fixture.agent,
fixture.fingerprint,
fixture.completionFile,
fixture.runtimeEnvironmentFile,
),
).toThrow(expect.objectContaining({ code: "ENOENT" }));
});

it("fails closed when the runtime handoff is symlinked", () => {
const fixture = writeCompletionFixture(managedStartupE2eProfile("openclaw"));
mockDescriptorOwnership(0n, 0n);
const replacement = `${fixture.runtimeEnvironmentFile}.replacement`;
fs.renameSync(fixture.runtimeEnvironmentFile, replacement);
fs.symlinkSync(replacement, fixture.runtimeEnvironmentFile);

expect(() =>
verifyManagedStartupImageCompletion(
fixture.agent,
fixture.fingerprint,
fixture.completionFile,
fixture.runtimeEnvironmentFile,
),
).toThrow(/refusing unsafe or unreadable file/u);
});

it("fails closed when the runtime handoff mode is not 0444", () => {
const fixture = writeCompletionFixture(managedStartupE2eProfile("hermes"));
mockDescriptorOwnership(0n, 0n);
fs.chmodSync(fixture.runtimeEnvironmentFile, 0o640);

expect(() =>
verifyManagedStartupImageCompletion(
fixture.agent,
fixture.fingerprint,
fixture.completionFile,
fixture.runtimeEnvironmentFile,
),
).toThrow(/runtime environment must be root:root mode 0444/u);
});

it("fails closed when the runtime handoff is not root owned", () => {
const fixture = writeCompletionFixture(managedStartupE2eProfile("langchain-deepagents-code"));
mockRuntimeDescriptorOwnership(fixture.runtimeEnvironmentFile, 501n, 20n);

expect(() =>
verifyManagedStartupImageCompletion(
fixture.agent,
fixture.fingerprint,
fixture.completionFile,
fixture.runtimeEnvironmentFile,
),
).toThrow(/runtime environment must be root:root mode 0444/u);
});

it("accepts merged CA paths without putting the CA payload in the readable handoff", () => {
const fixture = writeCompletionFixture(
managedStartupE2eProfile("langchain-deepagents-code", false, true),
Expand Down
Loading
Loading