Skip to content

Commit 973c35b

Browse files
committed
feat(policy): bind the accepted Pi trust boundary to a contract check
The Pi baseline policy already denies everything except the managed inference route, and the manifest already keeps project trust out of portable state. Nothing held either in place: the policy file was reachable from one existence check, so a direct provider endpoint, a package registry, an agent-writable network binary, a container-runtime socket, a relaxed Landlock mode, a privileged process identity, a dropped non-interactive approval flag, or a restorable project-trust decision would all have passed review unchallenged. The candidate artifact check now parses the policy and the manifest and binds each of those boundaries, so weakening one fails the check rather than the next release. A catalog preset name stays outside the Pi baseline, which keeps external access an explicit policy selection. Signed-off-by: Tinson Lai <tinsonl@nvidia.com>
1 parent 8cdc3c4 commit 973c35b

3 files changed

Lines changed: 310 additions & 9 deletions

File tree

scripts/checks/pi-candidate-artifacts.mts

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111
* one exact package version and integrity value, and verifies that the
1212
* candidate contract artifact name stays outside the all-agent cohort download
1313
* pattern.
14+
*
15+
* It also binds the accepted Pi trust boundary: the baseline permits only the
16+
* managed inference route from root-owned image binaries, writable paths stay
17+
* on the declared sandbox state, non-interactive runs keep passing the flag
18+
* that ignores project-local resources, and neither the project-trust store nor
19+
* the project-trust setting can travel through backup and restore.
1420
*/
1521

1622
import { createHash } from "node:crypto";
@@ -24,6 +30,23 @@ const MANAGED_IMAGE_CONTRACT_PATH = "src/lib/onboard/managed-image/contract.ts";
2430
const PI_PACKAGE = "@earendil-works/pi-coding-agent";
2531
const COHORT_CONTRACT_ARTIFACT_PREFIX = "managed-pr-contract-";
2632
const CANDIDATE_CONTRACT_ARTIFACT_PREFIX = "managed-candidate-contract-";
33+
const PI_MANIFEST_PATH = "agents/pi/manifest.yaml";
34+
const PI_POLICY_PATH = "agents/pi/policy-additions.yaml";
35+
36+
const MANAGED_INFERENCE_POLICY = "managed_inference";
37+
const MANAGED_INFERENCE_HOST = "inference.local";
38+
const MANAGED_INFERENCE_PORT = 443;
39+
const APPROVED_NETWORK_BINARIES = [
40+
"/usr/local/bin/node",
41+
"/usr/local/bin/pi",
42+
"/usr/local/lib/nemoclaw/pi-runtime/**",
43+
];
44+
const APPROVED_READ_WRITE_PATHS = ["/dev/null", "/sandbox", "/sandbox/.pi", "/tmp"];
45+
const REQUIRED_LANDLOCK_COMPATIBILITY = "strict";
46+
const REQUIRED_SANDBOX_IDENTITY = "sandbox";
47+
const NON_INTERACTIVE_APPROVAL_FLAG = "--no-approve";
48+
const PROJECT_TRUST_STORE = "trust.json";
49+
const PROJECT_TRUST_SETTING = "defaultProjectTrust";
2750

2851
const REQUIRED_ARTIFACTS = [
2952
"agents/pi/Dockerfile",
@@ -46,6 +69,7 @@ export type PiArtifactSources = Readonly<{
4669
managedImagesWorkflow: string;
4770
manifest: string;
4871
packageJson: string;
72+
policyAdditions: string;
4973
}>;
5074

5175
function readDockerfileArg(source: string, name: string): string | null {
@@ -228,12 +252,156 @@ function verifyCohortSeparation(workflow: string): string[] {
228252
return failures;
229253
}
230254

255+
type LooseRecord = Record<string, unknown>;
256+
257+
function asRecord(value: unknown): LooseRecord {
258+
return typeof value === "object" && value !== null && !Array.isArray(value)
259+
? (value as LooseRecord)
260+
: {};
261+
}
262+
263+
function sortedStrings(value: unknown): string[] {
264+
if (!Array.isArray(value)) return [];
265+
return value.filter((entry): entry is string => typeof entry === "string").sort();
266+
}
267+
268+
function sameSet(actual: readonly string[], approved: readonly string[]): boolean {
269+
return actual.length === approved.length && actual.every((entry, index) => entry === approved[index]);
270+
}
271+
272+
function verifyNetworkBoundary(policy: LooseRecord): string[] {
273+
const failures: string[] = [];
274+
const networkPolicies = asRecord(policy.network_policies);
275+
const declared = Object.keys(networkPolicies).sort();
276+
if (!sameSet(declared, [MANAGED_INFERENCE_POLICY])) {
277+
failures.push(
278+
`${PI_POLICY_PATH}: the baseline must declare only ${MANAGED_INFERENCE_POLICY}, found ${declared.join(", ") || "none"}`,
279+
);
280+
return failures;
281+
}
282+
const managed = asRecord(networkPolicies[MANAGED_INFERENCE_POLICY]);
283+
const endpoints = Array.isArray(managed.endpoints) ? managed.endpoints : [];
284+
if (endpoints.length !== 1) {
285+
failures.push(`${PI_POLICY_PATH}: ${MANAGED_INFERENCE_POLICY} must declare exactly one endpoint`);
286+
}
287+
for (const entry of endpoints) {
288+
const endpoint = asRecord(entry);
289+
if (endpoint.host !== MANAGED_INFERENCE_HOST || endpoint.port !== MANAGED_INFERENCE_PORT) {
290+
failures.push(
291+
`${PI_POLICY_PATH}: the baseline permits only ${MANAGED_INFERENCE_HOST}:${String(MANAGED_INFERENCE_PORT)}`,
292+
);
293+
}
294+
if (endpoint.enforcement !== "enforce") {
295+
failures.push(`${PI_POLICY_PATH}: ${MANAGED_INFERENCE_HOST} must stay enforced, not observed`);
296+
}
297+
const rules = Array.isArray(endpoint.rules) ? endpoint.rules : [];
298+
for (const rule of rules) {
299+
const allow = asRecord(asRecord(rule).allow);
300+
const rulePath = typeof allow.path === "string" ? allow.path : "";
301+
if (!Object.hasOwn(asRecord(rule), "allow") || !rulePath.startsWith("/v1/")) {
302+
failures.push(
303+
`${PI_POLICY_PATH}: every managed inference rule must allow an explicit /v1/ route, found ${rulePath || "an unreadable rule"}`,
304+
);
305+
}
306+
}
307+
}
308+
const binaries = sortedStrings(
309+
Array.isArray(managed.binaries)
310+
? managed.binaries.map((entry) => asRecord(entry).path)
311+
: [],
312+
);
313+
if (!sameSet(binaries, APPROVED_NETWORK_BINARIES)) {
314+
failures.push(
315+
`${PI_POLICY_PATH}: network capability must stay on the root-owned image binaries ${APPROVED_NETWORK_BINARIES.join(", ")}`,
316+
);
317+
}
318+
return failures;
319+
}
320+
321+
function verifyFilesystemBoundary(policy: LooseRecord): string[] {
322+
const failures: string[] = [];
323+
const filesystem = asRecord(policy.filesystem_policy);
324+
const readWrite = sortedStrings(filesystem.read_write);
325+
if (!sameSet(readWrite, APPROVED_READ_WRITE_PATHS)) {
326+
failures.push(
327+
`${PI_POLICY_PATH}: writable paths must stay ${APPROVED_READ_WRITE_PATHS.join(", ")}, found ${readWrite.join(", ") || "none"}`,
328+
);
329+
}
330+
if (asRecord(policy.landlock).compatibility !== REQUIRED_LANDLOCK_COMPATIBILITY) {
331+
failures.push(
332+
`${PI_POLICY_PATH}: landlock.compatibility must be ${REQUIRED_LANDLOCK_COMPATIBILITY} so filesystem policy fails closed`,
333+
);
334+
}
335+
const process = asRecord(policy.process);
336+
if (
337+
process.run_as_user !== REQUIRED_SANDBOX_IDENTITY ||
338+
process.run_as_group !== REQUIRED_SANDBOX_IDENTITY
339+
) {
340+
failures.push(`${PI_POLICY_PATH}: Pi must run as the ${REQUIRED_SANDBOX_IDENTITY} user and group`);
341+
}
342+
return failures;
343+
}
344+
345+
function verifyApprovalBoundary(manifest: LooseRecord): string[] {
346+
const failures: string[] = [];
347+
const runtime = asRecord(manifest.runtime);
348+
const headless = typeof runtime.headless_command === "string" ? runtime.headless_command : "";
349+
if (!headless.split(/\s+/u).includes(NON_INTERACTIVE_APPROVAL_FLAG)) {
350+
failures.push(
351+
`${PI_MANIFEST_PATH}: runtime.headless_command must pass ${NON_INTERACTIVE_APPROVAL_FLAG} so non-interactive runs ignore project-local resources`,
352+
);
353+
}
354+
if (asRecord(manifest.mcp).support !== "disabled") {
355+
failures.push(`${PI_MANIFEST_PATH}: mcp.support must stay disabled for the accepted v1 surface`);
356+
}
357+
if (manifest.device_pairing !== false) {
358+
failures.push(`${PI_MANIFEST_PATH}: device_pairing must stay false for the accepted v1 surface`);
359+
}
360+
return failures;
361+
}
362+
363+
function verifyProjectTrustBoundary(manifest: LooseRecord): string[] {
364+
const failures: string[] = [];
365+
const stateDirs = Array.isArray(manifest.state_dirs) ? manifest.state_dirs : [];
366+
const stateFiles = Array.isArray(manifest.state_files) ? manifest.state_files : [];
367+
const declared = [...stateDirs, ...stateFiles].map((entry) => asRecord(entry).path);
368+
if (declared.includes(PROJECT_TRUST_STORE)) {
369+
failures.push(
370+
`${PI_MANIFEST_PATH}: ${PROJECT_TRUST_STORE} must stay undeclared so a restore cannot carry a project-trust decision`,
371+
);
372+
}
373+
for (const entry of stateFiles) {
374+
const stateFile = asRecord(entry);
375+
const userKeys = Array.isArray(asRecord(stateFile.restore).user_keys)
376+
? (asRecord(stateFile.restore).user_keys as unknown[])
377+
: [];
378+
if (userKeys.map((key) => asRecord(key).key).includes(PROJECT_TRUST_SETTING)) {
379+
failures.push(
380+
`${PI_MANIFEST_PATH}: ${PROJECT_TRUST_SETTING} must stay outside the restore allowlist so a backup cannot widen project trust`,
381+
);
382+
}
383+
}
384+
return failures;
385+
}
386+
387+
export function verifyPiTrustBoundary(sources: PiArtifactSources): string[] {
388+
const policy = asRecord(parseYaml(sources.policyAdditions));
389+
const manifest = asRecord(parseYaml(sources.manifest));
390+
return [
391+
...verifyNetworkBoundary(policy),
392+
...verifyFilesystemBoundary(policy),
393+
...verifyApprovalBoundary(manifest),
394+
...verifyProjectTrustBoundary(manifest),
395+
];
396+
}
397+
231398
export function verifyPiCandidateArtifacts(sources: PiArtifactSources): string[] {
232399
return [
233400
...verifyPinnedIdentity(sources),
234401
...verifyCandidateRegistration(sources.managedImageContract),
235402
...verifyCohortSeparation(sources.managedImagesWorkflow),
236403
...verifyManagedImageDeclaration(sources),
404+
...verifyPiTrustBoundary(sources),
237405
];
238406
}
239407

@@ -256,8 +424,9 @@ function main(): void {
256424
lock: readRepoFile("agents/pi/pi-runtime/package-lock.json"),
257425
managedImageContract: readRepoFile(MANAGED_IMAGE_CONTRACT_PATH),
258426
managedImagesWorkflow: readRepoFile(".github/workflows/managed-images.yaml"),
259-
manifest: readRepoFile("agents/pi/manifest.yaml"),
427+
manifest: readRepoFile(PI_MANIFEST_PATH),
260428
packageJson: readRepoFile("agents/pi/pi-runtime/package.json"),
429+
policyAdditions: readRepoFile(PI_POLICY_PATH),
261430
});
262431
if (failures.length > 0) {
263432
console.error(failures.join("\n"));

src/lib/policy/agent-base-preset.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,14 @@ describe("agent base preset detection", () => {
6666

6767
expect(isAgentBasePreset("hermes", "pypi")).toBe(true);
6868
});
69+
70+
it("keeps a catalog preset outside the Pi baseline so external access stays an explicit choice (#7924)", () => {
71+
const piPolicy = fs.readFileSync(path.join(AGENTS_DIR, "pi", "policy-additions.yaml"), "utf8");
72+
const agent = createAgentFixture(piPolicy);
73+
vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "pi", agent } as never);
74+
75+
expect(isAgentBasePreset("pi", "managed_inference")).toBe(true);
76+
expect(isAgentBasePreset("pi", "pypi")).toBe(false);
77+
expect(isAgentBasePreset("pi", "github")).toBe(false);
78+
});
6979
});

test/pi-candidate-runtime-artifacts.test.ts

Lines changed: 130 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import YAML from "yaml";
1111
import {
1212
type PiArtifactSources,
1313
verifyPiCandidateArtifacts,
14+
verifyPiTrustBoundary,
1415
} from "../scripts/checks/pi-candidate-artifacts.mts";
1516
import {
1617
CANDIDATE_MANAGED_IMAGE_AGENTS,
@@ -34,9 +35,24 @@ function currentSources(): PiArtifactSources {
3435
managedImagesWorkflow: readRepoFile(".github/workflows/managed-images.yaml"),
3536
manifest: readRepoFile("agents/pi/manifest.yaml"),
3637
packageJson: readRepoFile("agents/pi/pi-runtime/package.json"),
38+
policyAdditions: readRepoFile("agents/pi/policy-additions.yaml"),
3739
};
3840
}
3941

42+
function withPolicy(mutate: (policy: Record<string, any>) => void): PiArtifactSources {
43+
const sources = currentSources();
44+
const policy = YAML.parse(sources.policyAdditions);
45+
mutate(policy);
46+
return { ...sources, policyAdditions: YAML.stringify(policy) };
47+
}
48+
49+
function withManifest(mutate: (manifest: Record<string, any>) => void): PiArtifactSources {
50+
const sources = currentSources();
51+
const manifest = YAML.parse(sources.manifest);
52+
mutate(manifest);
53+
return { ...sources, manifest: YAML.stringify(manifest) };
54+
}
55+
4056
const DIGEST = `sha256:${"a".repeat(64)}`;
4157

4258
function candidateContract(overrides: Record<string, unknown> = {}): Record<string, unknown> {
@@ -191,17 +207,123 @@ describe("Pi candidate contract validation", () => {
191207
});
192208

193209
describe("Pi runtime boundaries", () => {
194-
const APPROVED_MANAGED_INFERENCE_BINARY_PATHS = [
195-
"/usr/local/bin/pi",
196-
"/usr/local/bin/node",
197-
"/usr/local/lib/nemoclaw/pi-runtime/**",
198-
];
210+
it("accepts the trust boundary committed in this repository (#7924)", () => {
211+
expect(verifyPiTrustBoundary(currentSources())).toEqual([]);
212+
});
213+
214+
it("denies a direct provider endpoint added beside the managed route (#7924)", () => {
215+
const sources = withPolicy((policy) => {
216+
policy.network_policies.managed_inference.endpoints.push({
217+
host: "api.openai.com",
218+
port: 443,
219+
protocol: "rest",
220+
enforcement: "enforce",
221+
rules: [{ allow: { method: "POST", path: "/v1/chat/completions" } }],
222+
});
223+
});
224+
expect(verifyPiTrustBoundary(sources)).toContain(
225+
"agents/pi/policy-additions.yaml: the baseline permits only inference.local:443",
226+
);
227+
});
228+
229+
it("denies a package registry policy the baseline never selected (#7924)", () => {
230+
const sources = withPolicy((policy) => {
231+
policy.network_policies.npm_registry = {
232+
name: "npm_registry",
233+
endpoints: [{ host: "registry.npmjs.org", port: 443, enforcement: "enforce", rules: [] }],
234+
};
235+
});
236+
expect(verifyPiTrustBoundary(sources)).toContain(
237+
"agents/pi/policy-additions.yaml: the baseline must declare only managed_inference, found managed_inference, npm_registry",
238+
);
239+
});
240+
241+
it("denies network capability to a binary the agent can write (#7924)", () => {
242+
const sources = withPolicy((policy) => {
243+
policy.network_policies.managed_inference.binaries.push({ path: "/sandbox/agent-proxy" });
244+
});
245+
expect(verifyPiTrustBoundary(sources)).toContain(
246+
"agents/pi/policy-additions.yaml: network capability must stay on the root-owned image binaries /usr/local/bin/node, /usr/local/bin/pi, /usr/local/lib/nemoclaw/pi-runtime/**",
247+
);
248+
});
249+
250+
it("denies a rule that widens the managed route beyond its versioned paths (#7924)", () => {
251+
const sources = withPolicy((policy) => {
252+
policy.network_policies.managed_inference.endpoints[0].rules.push({
253+
allow: { method: "GET", path: "/**" },
254+
});
255+
});
256+
expect(verifyPiTrustBoundary(sources)).toContain(
257+
"agents/pi/policy-additions.yaml: every managed inference rule must allow an explicit /v1/ route, found /**",
258+
);
259+
});
260+
261+
it("denies a container-runtime socket added to the writable paths (#7924)", () => {
262+
const sources = withPolicy((policy) => {
263+
policy.filesystem_policy.read_write.push("/var/run/docker.sock");
264+
});
265+
expect(verifyPiTrustBoundary(sources).join("\n")).toContain(
266+
"writable paths must stay /dev/null, /sandbox, /sandbox/.pi, /tmp",
267+
);
268+
});
269+
270+
it("denies a relaxed Landlock compatibility that would start with policy unenforced (#7924)", () => {
271+
const sources = withPolicy((policy) => {
272+
policy.landlock.compatibility = "best-effort";
273+
});
274+
expect(verifyPiTrustBoundary(sources)).toContain(
275+
"agents/pi/policy-additions.yaml: landlock.compatibility must be strict so filesystem policy fails closed",
276+
);
277+
});
199278

200-
it("excludes an agent-writable binary path from the approved allowlist", () => {
201-
expect(APPROVED_MANAGED_INFERENCE_BINARY_PATHS).not.toContain("/tmp/agent-proxy");
202-
expect(APPROVED_MANAGED_INFERENCE_BINARY_PATHS).not.toContain("/sandbox/agent-proxy");
279+
it("denies host control by refusing a privileged process identity (#7924)", () => {
280+
const sources = withPolicy((policy) => {
281+
policy.process.run_as_user = "root";
282+
});
283+
expect(verifyPiTrustBoundary(sources)).toContain(
284+
"agents/pi/policy-additions.yaml: Pi must run as the sandbox user and group",
285+
);
286+
});
287+
288+
it("denies a non-interactive command that stops ignoring project-local resources (#7924)", () => {
289+
const sources = withManifest((manifest) => {
290+
manifest.runtime.headless_command = "pi --print";
291+
});
292+
expect(verifyPiTrustBoundary(sources)).toContain(
293+
"agents/pi/manifest.yaml: runtime.headless_command must pass --no-approve so non-interactive runs ignore project-local resources",
294+
);
203295
});
204296

297+
it("denies an MCP surface the accepted v1 scope excludes (#7924)", () => {
298+
const sources = withManifest((manifest) => {
299+
manifest.mcp.support = "enabled";
300+
});
301+
expect(verifyPiTrustBoundary(sources)).toContain(
302+
"agents/pi/manifest.yaml: mcp.support must stay disabled for the accepted v1 surface",
303+
);
304+
});
305+
306+
it("denies a project-trust store that a restore could carry into a new sandbox (#7924)", () => {
307+
const sources = withManifest((manifest) => {
308+
manifest.state_files.push({ path: "trust.json" });
309+
});
310+
expect(verifyPiTrustBoundary(sources)).toContain(
311+
"agents/pi/manifest.yaml: trust.json must stay undeclared so a restore cannot carry a project-trust decision",
312+
);
313+
});
314+
315+
it("denies a restore allowlist that could widen project trust (#7924)", () => {
316+
const sources = withManifest((manifest) => {
317+
manifest.state_files[0].restore.user_keys.push({
318+
key: "defaultProjectTrust",
319+
type: "enum",
320+
values: ["ask", "always", "never"],
321+
});
322+
});
323+
expect(verifyPiTrustBoundary(sources)).toContain(
324+
"agents/pi/manifest.yaml: defaultProjectTrust must stay outside the restore allowlist so a backup cannot widen project trust",
325+
);
326+
});
205327
});
206328

207329
describe("Pi managed model catalog generation", () => {

0 commit comments

Comments
 (0)