diff --git a/config/pi-package-lock.json b/config/pi-package-lock.json index b213b52..b72e398 100644 --- a/config/pi-package-lock.json +++ b/config/pi-package-lock.json @@ -182,14 +182,16 @@ }, { "name": "lm-studio", - "source": "$HOME/.pi/agent/extensions/lm-studio.ts", - "installed": { - "sha256": "474d1288cb7c528e44b14fcc498ae9f4be04fcbb248dd22d76adb13c2f8c134e" + "source": "extensions/lm-studio/index.ts", + "reviewed": { + "sha256": "0c9926f8f5b549d4e0ce3a97c01a56d4955c5bcc337169ab9dcb05c7347364d9", + "legacySourceSha256": "474d1288cb7c528e44b14fcc498ae9f4be04fcbb248dd22d76adb13c2f8c134e", + "baseUrl": "http://127.0.0.1:1234/v1" }, "status": "blocked", "blockers": [ - "The active extension is an unmanaged regular file with no immutable source revision.", - "Slice 1 must import the reviewed implementation and quarantine the unmanaged source before activation." + "The active unmanaged $HOME/.pi/agent/extensions/lm-studio.ts source has not completed the approved maintenance quarantine.", + "The reviewed repository source must be promoted into an immutable installed snapshot before normal-profile activation." ] }, { diff --git a/docs/adr/0003-trusted-nono-policy-composition-27AUG2026.md b/docs/adr/0003-trusted-nono-policy-composition-27AUG2026.md new file mode 100644 index 0000000..e84b0fc --- /dev/null +++ b/docs/adr/0003-trusted-nono-policy-composition-27AUG2026.md @@ -0,0 +1,13 @@ +# Materialize trusted Nono policy composition before launch + +Nono 0.74 accepts multiple `extends` parents, merges additive grants and environment allowlists across them, and preserves restrictive network decisions such as `block: true`. The trusted Slice 3 generator will therefore treat the Slice 1 core and provider profiles as reviewed policy inputs, select exactly one core and one provider, materialize one canonical launch profile, and validate that result rather than launching a multiple-inheritance profile directly. This keeps the reusable core fail-closed when validated alone while allowing the trusted generator to make the selected provider route explicit. + +## Consequences + +The managed LM Studio extension uses exact URL `http://127.0.0.1:1234/v1`, while Nono's Linux policy grants outbound TCP port `1234`. Nono 0.74 Landlock mediation is port-only and cannot constrain that grant to loopback, so compromised sandbox code could connect to another address on port 1234. The adjacent-port behavioral proof and exact extension endpoint make this narrower than broad networking without overstating host enforcement. + +Cloud provider fragments enable Nono networking with an explicit empty direct-domain allowlist and exactly one phantom credential route. The route's reviewed upstream is the only proxy destination; raw provider keys remain denied to the sandbox. Live secret-backed verification stays behind a separate explicit approval gate. + +The active unmanaged LM Studio source remains in place during Slice 1 by explicit approval because quarantining it before an immutable managed installation would disable the current provider. Its reviewed legacy digest and both blockers are recorded in `config/pi-package-lock.json`; Slice 3 must perform hash-guarded quarantine atomically with immutable promotion before activation. + +Provider fragments are not standalone launch profiles despite passing strict schema validation. They exclude the inherited Linux and macOS system runtime-read groups, so direct execution cannot read its executable; the trusted generator extracts only the reviewed provider route into a core that owns runtime reads. Untrusted repositories cannot compose or override fragments, and every generated profile must prove that it contains one state namespace, one worktree grant, one temporary directory, and exactly one provider route before `nono run`. Slice 1 isolated settings expose only Pi's `read`, `edit`, and `write` built-ins; model-visible `bash`, package tools, notification commands, background-job extensions, and project resources are absent. Core profiles intentionally define no `command_policies`; later broker slices must bind each controlled command to an exact executable and declare every permitted caller under `commands..from`, because inherited denials and caller edges are not safely loosened by child profiles. Slice 3 must invoke the exact Pi entrypoint and must not expose arbitrary caller-selected Nono commands as a launch interface. diff --git a/docs/todos.md b/docs/todos.md index a18e3ae..996434b 100644 --- a/docs/todos.md +++ b/docs/todos.md @@ -4,6 +4,10 @@ A Slice 0 `node scripts/pi-links.mjs prune --dry-run` from `.worktrees/nono-dynamic-permissions-slice-zero-direct` exited 2 and classified every active link into the primary checkout as an outside-repository conflict. The tool anchors ownership to the current worktree root, while active links correctly target the primary repository checkout. Teach check and dry-run planning to recognize canonical checkouts sharing the same Git common directory without broadening stale-link deletion to unrelated repositories. Add linked-worktree fixtures before changing prune behavior. Until fixed, run host reconciliation only from the integrated primary checkout. +## Harden Nono 0.74 command-policy and resolved-policy diagnostics + +Slice 1 found two Nono 0.74 limitations while building behavioral policy proofs. First, command-policy startup fails with `path must be shorter than SUN_LEN` when Nono creates its supervisor socket beneath a moderately long isolated `TMPDIR` such as `/tmp/nono-slice-one-*/sessions/personal/tmp`. Before broker profiles activate command policies, reproduce the maximum safe path, then have the launcher select a private short socket root or confirm an upstream fix. Second, `nono profile show --json` omits resolved `environment`, `command_policies`, `network.connect_port`, and `network.custom_credentials` fields even though strict validation and live enforcement accept them. Do not treat resolved JSON as complete evidence for these capabilities; retain behavioral `nono run` and `nono why --command` proofs until the output is complete and regression-tested. + ## Replace `pi-workflow` with a skill-based orchestrator The strict `pi-workflow` extension repeatedly failed before implementation because planner output and persisted state-machine constraints were too brittle for adaptive subagent orchestration. In a separate session, design a repository-managed skill where the main Pi agent owns the plan, task tree, isolated worktree, explicit user approval gates, verification, and independent review while launching standard `scout` and `worker` subagents directly. Preserve file-ownership boundaries, no inferred approvals, review independence, and resumable repository evidence, but prefer native Pi primitives over a custom orchestration engine. Evaluate a small committed state manifest for crash recovery before retiring or archiving the extension. Use the abandoned `.worktrees/pi-workflow-plan-seed` implementation only as reference; do not integrate it by default. diff --git a/extensions/lm-studio/index.test.ts b/extensions/lm-studio/index.test.ts new file mode 100644 index 0000000..ec518dc --- /dev/null +++ b/extensions/lm-studio/index.test.ts @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import registerLmStudio from "./index.ts"; + +type Registration = { + name: string; + config: { + baseUrl?: string; + refreshModels?: (context: { + allowNetwork: boolean; + signal: AbortSignal; + stored?: { models: readonly unknown[] }; + }) => Promise; + }; +}; + +function captureRegistration(): Registration { + let registration: Registration | undefined; + registerLmStudio({ + registerProvider(name: string, config: Registration["config"]) { + registration = { name, config }; + }, + } as unknown as ExtensionAPI); + assert.ok(registration); + return registration; +} + +test("registers one LM Studio provider at the exact loopback endpoint", () => { + const registration = captureRegistration(); + assert.equal(registration.name, "lm-studio"); + assert.equal(registration.config.baseUrl, "http://127.0.0.1:1234/v1"); +}); + +test("refresh validates and maps the LM Studio model catalog", async () => { + const registration = captureRegistration(); + assert.ok(registration.config.refreshModels); + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + assert.equal(String(input), "http://127.0.0.1:1234/v1/models"); + return new Response( + JSON.stringify({ + data: [ + { + id: "fixture-model", + name: "Fixture Model", + context_window: 32768, + max_tokens: 4096, + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }; + try { + const models = await registration.config.refreshModels({ + allowNetwork: true, + signal: AbortSignal.timeout(1000), + }); + assert.deepEqual(models, [ + { + id: "fixture-model", + name: "Fixture Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 32768, + maxTokens: 4096, + compat: { + supportsDeveloperRole: false, + supportsReasoningEffort: false, + supportsUsageInStreaming: false, + maxTokensField: "max_tokens", + }, + }, + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("refresh reuses stored models when network access is disabled", async () => { + const registration = captureRegistration(); + assert.ok(registration.config.refreshModels); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + throw new Error("offline refresh attempted network access"); + }; + const stored = [{ id: "stored-model" }]; + try { + const models = await registration.config.refreshModels({ + allowNetwork: false, + signal: AbortSignal.timeout(1000), + stored: { models: stored }, + }); + assert.deepEqual(models, stored); + assert.notEqual(models, stored); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("refresh rejects malformed model catalogs", async () => { + const registration = captureRegistration(); + assert.ok(registration.config.refreshModels); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response(JSON.stringify({ data: "not-an-array" }), { status: 200 }); + try { + await assert.rejects( + registration.config.refreshModels({ + allowNetwork: true, + signal: AbortSignal.timeout(1000), + }), + /invalid \/v1\/models response/, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/extensions/lm-studio/index.ts b/extensions/lm-studio/index.ts new file mode 100644 index 0000000..5a00504 --- /dev/null +++ b/extensions/lm-studio/index.ts @@ -0,0 +1,72 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const BASE_URL = "http://127.0.0.1:1234/v1"; +const DEFAULT_CONTEXT_WINDOW = 128_000; +const DEFAULT_MAX_TOKENS = 16_384; + +type LmStudioModel = { + id?: unknown; + name?: unknown; + context_window?: unknown; + max_tokens?: unknown; +}; + +type LmStudioModelsResponse = { + data?: LmStudioModel[]; +}; + +function positiveInteger(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isInteger(value) && value > 0 + ? value + : fallback; +} + +export default function (pi: ExtensionAPI) { + pi.registerProvider("lm-studio", { + name: "LM Studio", + baseUrl: BASE_URL, + apiKey: "lm-studio", + api: "openai-completions", + models: [], + async refreshModels({ allowNetwork, signal, stored }) { + if (!allowNetwork) return [...(stored?.models ?? [])]; + + const response = await fetch(`${BASE_URL}/models`, { signal }); + if (!response.ok) { + throw new Error( + `LM Studio model discovery failed: ${response.status} ${response.statusText}`, + ); + } + + const payload = (await response.json()) as LmStudioModelsResponse; + if (!Array.isArray(payload.data)) { + throw new Error("LM Studio returned an invalid /v1/models response"); + } + + return payload.data.flatMap((model) => { + if (typeof model.id !== "string" || model.id.length === 0) return []; + + return [ + { + id: model.id, + name: typeof model.name === "string" ? model.name : model.id, + reasoning: false, + input: ["text"] as ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: positiveInteger( + model.context_window, + DEFAULT_CONTEXT_WINDOW, + ), + maxTokens: positiveInteger(model.max_tokens, DEFAULT_MAX_TOKENS), + compat: { + supportsDeveloperRole: false, + supportsReasoningEffort: false, + supportsUsageInStreaming: false, + maxTokensField: "max_tokens" as const, + }, + }, + ]; + }); + }, + }); +} diff --git a/profiles/nono/pi-core-personal.json b/profiles/nono/pi-core-personal.json new file mode 100644 index 0000000..717467f --- /dev/null +++ b/profiles/nono/pi-core-personal.json @@ -0,0 +1,87 @@ +{ + "extends": "default", + "meta": { + "name": "pi-core-personal", + "description": "Restrictive personal Pi baseline without repository or provider access" + }, + "groups": { + "exclude": [ + "homebrew_linux", + "homebrew_macos", + "system_write_linux", + "system_write_macos", + "user_tools" + ] + }, + "security": { + "signal_mode": "isolated", + "capability_elevation": false + }, + "workdir": { + "access": "none" + }, + "filesystem": { + "allow": [ + "$XDG_CACHE_HOME/pi-sbx/personal", + "$XDG_STATE_HOME/pi-sbx/personal/agent", + "$XDG_STATE_HOME/pi-sbx/personal/sessions" + ], + "read": [ + "/home/jb/.bun/install/global/node_modules/@earendil-works/pi-coding-agent", + "/home/jb/.bun/install/global/node_modules/jiti" + ], + "allow_file": [ + "/dev/null", + "/dev/stderr", + "/dev/stdout", + "/dev/tty" + ], + "read_file": [ + "/home/jb/.pi/agent/AGENTS.md", + "/usr/bin/node" + ] + }, + "linux": { + "af_unix_mediation": "pathname" + }, + "network": { + "block": true + }, + "environment": { + "allow_vars": [ + "COLORTERM", + "HOME", + "LANG", + "LC_ALL", + "NO_COLOR", + "PATH", + "PI_SBX_PROVIDER", + "SHELL", + "TERM", + "TMPDIR", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", + "XDG_RUNTIME_DIR", + "XDG_STATE_HOME" + ], + "deny_vars": [ + "ANTHROPIC_API_KEY", + "GH_TOKEN", + "GITHUB_TOKEN", + "MORPH_API_KEY", + "OPENAI_API_KEY", + "SSH_AUTH_SOCK" + ], + "set_vars": { + "PI_CODING_AGENT_DIR": "$XDG_STATE_HOME/pi-sbx/personal/agent", + "PI_CODING_AGENT_SESSION_DIR": "$XDG_STATE_HOME/pi-sbx/personal/sessions", + "PI_SKIP_VERSION_CHECK": "1", + "PI_TELEMETRY": "0" + } + }, + "open_urls": { + "allow_origins": [], + "allow_localhost": false + }, + "allow_launch_services": false +} diff --git a/profiles/nono/pi-core-work.json b/profiles/nono/pi-core-work.json new file mode 100644 index 0000000..803215e --- /dev/null +++ b/profiles/nono/pi-core-work.json @@ -0,0 +1,87 @@ +{ + "extends": "default", + "meta": { + "name": "pi-core-work", + "description": "Restrictive work Pi baseline without repository or provider access" + }, + "groups": { + "exclude": [ + "homebrew_linux", + "homebrew_macos", + "system_write_linux", + "system_write_macos", + "user_tools" + ] + }, + "security": { + "signal_mode": "isolated", + "capability_elevation": false + }, + "workdir": { + "access": "none" + }, + "filesystem": { + "allow": [ + "$XDG_CACHE_HOME/pi-sbx/work", + "$XDG_STATE_HOME/pi-sbx/work/agent", + "$XDG_STATE_HOME/pi-sbx/work/sessions" + ], + "read": [ + "/home/jb/.bun/install/global/node_modules/@earendil-works/pi-coding-agent", + "/home/jb/.bun/install/global/node_modules/jiti" + ], + "allow_file": [ + "/dev/null", + "/dev/stderr", + "/dev/stdout", + "/dev/tty" + ], + "read_file": [ + "/home/jb/.pi/agent/AGENTS.md", + "/usr/bin/node" + ] + }, + "linux": { + "af_unix_mediation": "pathname" + }, + "network": { + "block": true + }, + "environment": { + "allow_vars": [ + "COLORTERM", + "HOME", + "LANG", + "LC_ALL", + "NO_COLOR", + "PATH", + "PI_SBX_PROVIDER", + "SHELL", + "TERM", + "TMPDIR", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", + "XDG_RUNTIME_DIR", + "XDG_STATE_HOME" + ], + "deny_vars": [ + "ANTHROPIC_API_KEY", + "GH_TOKEN", + "GITHUB_TOKEN", + "MORPH_API_KEY", + "OPENAI_API_KEY", + "SSH_AUTH_SOCK" + ], + "set_vars": { + "PI_CODING_AGENT_DIR": "$XDG_STATE_HOME/pi-sbx/work/agent", + "PI_CODING_AGENT_SESSION_DIR": "$XDG_STATE_HOME/pi-sbx/work/sessions", + "PI_SKIP_VERSION_CHECK": "1", + "PI_TELEMETRY": "0" + } + }, + "open_urls": { + "allow_origins": [], + "allow_localhost": false + }, + "allow_launch_services": false +} diff --git a/profiles/nono/provider-anthropic.json b/profiles/nono/provider-anthropic.json new file mode 100644 index 0000000..1e12fe5 --- /dev/null +++ b/profiles/nono/provider-anthropic.json @@ -0,0 +1,64 @@ +{ + "extends": "default", + "meta": { + "name": "provider-anthropic", + "description": "Anthropic route with a Nono-mediated phantom credential" + }, + "groups": { + "exclude": [ + "homebrew_linux", + "homebrew_macos", + "system_read_linux_core", + "system_read_macos", + "system_write_linux", + "system_write_macos", + "user_tools" + ] + }, + "workdir": { + "access": "none" + }, + "network": { + "block": false, + "allow_domain": [], + "credentials": [ + "anthropic" + ], + "custom_credentials": { + "anthropic": { + "upstream": "https://api.anthropic.com", + "credential_key": "ANTHROPIC_API_KEY", + "inject_header": "x-api-key", + "credential_format": "{}", + "env_var": "ANTHROPIC_API_KEY" + } + } + }, + "environment": { + "allow_vars": [ + "HOME", + "LANG", + "LC_ALL", + "PATH", + "TERM", + "TMPDIR", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", + "XDG_RUNTIME_DIR", + "XDG_STATE_HOME" + ], + "deny_vars": [ + "ANTHROPIC_API_KEY", + "GH_TOKEN", + "GITHUB_TOKEN", + "MORPH_API_KEY", + "OPENAI_API_KEY", + "SSH_AUTH_SOCK" + ] + }, + "open_urls": { + "allow_origins": [], + "allow_localhost": false + }, + "allow_launch_services": false +} diff --git a/profiles/nono/provider-lm-studio.json b/profiles/nono/provider-lm-studio.json new file mode 100644 index 0000000..38a4738 --- /dev/null +++ b/profiles/nono/provider-lm-studio.json @@ -0,0 +1,54 @@ +{ + "extends": "default", + "meta": { + "name": "provider-lm-studio", + "description": "Outbound TCP port 1234 for the managed loopback-only LM Studio extension" + }, + "groups": { + "exclude": [ + "homebrew_linux", + "homebrew_macos", + "system_read_linux_core", + "system_read_macos", + "system_write_linux", + "system_write_macos", + "user_tools" + ] + }, + "workdir": { + "access": "none" + }, + "network": { + "block": false, + "connect_port": [ + 1234 + ] + }, + "environment": { + "allow_vars": [ + "HOME", + "LANG", + "LC_ALL", + "PATH", + "TERM", + "TMPDIR", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", + "XDG_RUNTIME_DIR", + "XDG_STATE_HOME" + ], + "deny_vars": [ + "ANTHROPIC_API_KEY", + "GH_TOKEN", + "GITHUB_TOKEN", + "MORPH_API_KEY", + "OPENAI_API_KEY", + "SSH_AUTH_SOCK" + ] + }, + "open_urls": { + "allow_origins": [], + "allow_localhost": false + }, + "allow_launch_services": false +} diff --git a/profiles/nono/provider-morph.json b/profiles/nono/provider-morph.json new file mode 100644 index 0000000..34d2f95 --- /dev/null +++ b/profiles/nono/provider-morph.json @@ -0,0 +1,64 @@ +{ + "extends": "default", + "meta": { + "name": "provider-morph", + "description": "Morph route with a Nono-mediated phantom credential" + }, + "groups": { + "exclude": [ + "homebrew_linux", + "homebrew_macos", + "system_read_linux_core", + "system_read_macos", + "system_write_linux", + "system_write_macos", + "user_tools" + ] + }, + "workdir": { + "access": "none" + }, + "network": { + "block": false, + "allow_domain": [], + "credentials": [ + "morph" + ], + "custom_credentials": { + "morph": { + "upstream": "https://api.morphllm.com", + "credential_key": "MORPH_API_KEY", + "inject_header": "Authorization", + "credential_format": "Bearer {}", + "env_var": "MORPH_API_KEY" + } + } + }, + "environment": { + "allow_vars": [ + "HOME", + "LANG", + "LC_ALL", + "PATH", + "TERM", + "TMPDIR", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", + "XDG_RUNTIME_DIR", + "XDG_STATE_HOME" + ], + "deny_vars": [ + "ANTHROPIC_API_KEY", + "GH_TOKEN", + "GITHUB_TOKEN", + "MORPH_API_KEY", + "OPENAI_API_KEY", + "SSH_AUTH_SOCK" + ] + }, + "open_urls": { + "allow_origins": [], + "allow_localhost": false + }, + "allow_launch_services": false +} diff --git a/profiles/nono/provider-openai.json b/profiles/nono/provider-openai.json new file mode 100644 index 0000000..1ba3a54 --- /dev/null +++ b/profiles/nono/provider-openai.json @@ -0,0 +1,64 @@ +{ + "extends": "default", + "meta": { + "name": "provider-openai", + "description": "OpenAI route with a Nono-mediated phantom credential" + }, + "groups": { + "exclude": [ + "homebrew_linux", + "homebrew_macos", + "system_read_linux_core", + "system_read_macos", + "system_write_linux", + "system_write_macos", + "user_tools" + ] + }, + "workdir": { + "access": "none" + }, + "network": { + "block": false, + "allow_domain": [], + "credentials": [ + "openai" + ], + "custom_credentials": { + "openai": { + "upstream": "https://api.openai.com/v1", + "credential_key": "OPENAI_API_KEY", + "inject_header": "Authorization", + "credential_format": "Bearer {}", + "env_var": "OPENAI_API_KEY" + } + } + }, + "environment": { + "allow_vars": [ + "HOME", + "LANG", + "LC_ALL", + "PATH", + "TERM", + "TMPDIR", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", + "XDG_RUNTIME_DIR", + "XDG_STATE_HOME" + ], + "deny_vars": [ + "ANTHROPIC_API_KEY", + "GH_TOKEN", + "GITHUB_TOKEN", + "MORPH_API_KEY", + "OPENAI_API_KEY", + "SSH_AUTH_SOCK" + ] + }, + "open_urls": { + "allow_origins": [], + "allow_localhost": false + }, + "allow_launch_services": false +} diff --git a/skills/tools/nono-profiles/SKILL.md b/skills/tools/nono-profiles/SKILL.md index 435730b..64eff9f 100644 --- a/skills/tools/nono-profiles/SKILL.md +++ b/skills/tools/nono-profiles/SKILL.md @@ -43,9 +43,9 @@ Ask about exact files, credential routes, and Unix sockets only when the request Completion criterion: map every requested capability to an exact path, hostname, credential route, or socket and an explicit access level. -### 3. Choose a safe parent +### 3. Choose safe composition -Use one `extends` parent; build a transitive chain rather than multiple inheritance. +Nono 0.74 accepts either one `extends` parent or an ordered array. Multiple parents merge left to right, deduplicate shared ancestors, append list-valued grants and environment filters, and preserve restrictive decisions such as blocked networking. Use a single parent for ordinary profiles. Use multiple parents only when every additive capability and merge result has been reviewed with `nono profile show` and behavioral tests. Apply these inheritance rules: @@ -53,6 +53,7 @@ Apply these inheritance rules: - Extend `nolabs-ai/pi` for offline mode. Set `network.block` to `true`, clear `open_urls.allow_origins`, disable `open_urls.allow_localhost`, and set `allow_launch_services` to `false`. Do not extend allowlisted `pi-local`: inherited proxy domains conflict with blocked networking. - Extend `nolabs-ai/pi` rather than `pi-local` when an allowlisted child needs fewer domains than `pi-local`, or when creating an open-network profile. Reproduce only the required local filesystem/socket policy. - Edit `pi-local` only after showing how the change affects every future project launched with it. +- For trusted generated profiles, materialize the reviewed fragments into one canonical profile and validate that file. Do not rely on multiple inheritance to widen a blocked core with a provider route. An inherited broad capability cannot reliably be narrowed by an empty child array. Choose a restrictive parent instead. diff --git a/skills/tools/nono-profiles/references/policy-fields.md b/skills/tools/nono-profiles/references/policy-fields.md index 8e0c65c..a819cd2 100644 --- a/skills/tools/nono-profiles/references/policy-fields.md +++ b/skills/tools/nono-profiles/references/policy-fields.md @@ -4,9 +4,11 @@ Consult this reference when mapping interview answers into a profile. Treat `non ## Inheritance -Use a single string-valued `extends`. Local profiles may extend `pi-local`, registry profiles such as `nolabs-ai/pi`, or another available profile. Inheritance is transitive. +`extends` accepts one profile name or an ordered, non-empty array of profile names. Nono 0.74 merges multiple parents left to right and resolves each shared transitive ancestor once. -Treat included groups and list-valued capabilities as additive. An empty child list does not establish revocation. To create a narrower policy, derive from a narrower parent and add only the required capabilities. +Treat included groups, environment allow/deny lists, and list-valued capabilities as additive. An empty child list does not establish revocation. Restrictive decisions such as `network.block: true` remain restrictive when parent profiles are combined. To create a narrower policy, derive from a narrower parent and add only the required capabilities. When a trusted generator must combine a fail-closed core with one selected provider, materialize and validate one canonical profile instead of expecting inheritance to widen the blocked core. + +Nono 0.74 `profile show --json` omits resolved environment, command-policy, connect-port, and custom-credential fields. Use strict validation plus behavioral `nono run` or `nono why --command` proofs for those capabilities rather than treating the resolved JSON as complete evidence. ## Runtime working directory @@ -100,6 +102,10 @@ Set networking enabled without a domain allowlist inherited from the chosen pare State that open mode allows destinations beyond those currently known to the project. +## Exact loopback ports + +On Linux with Landlock network support, use `network.connect_port` for outbound-only access to exact TCP ports. Use `network.open_port` only when the sandbox must both connect and bind. A port rule does not establish hostname or credential access; validate those separately. Prove exact enforcement with live listeners on the selected port and an adjacent denied port because strict schema validation alone does not demonstrate kernel enforcement. + ## URL opening and service launching `open_urls.allow_origins` controls origins that sandboxed tools may ask the host to open. `open_urls.allow_localhost` controls localhost URL opening. These capabilities remain separate from direct Internet access. @@ -116,6 +122,10 @@ The installed `nolabs-ai/pi` parent enables selected OpenAI, Anthropic, and GitH Treat a credential route as a separate capability from reaching its domain. Confirm both the route and endpoint restrictions. Prefer nono-mediated credential routes over exposing real API keys inside the sandbox. +## Command policies + +Place each caller edge under `command_policies.commands..from.`. Inherited invocation denials remain restrictive when a child declares a more permissive default, so verify the effective decision with `nono why --profile --command --caller --json -- `. Nono 0.74 creates a command-policy supervisor Unix socket under `TMPDIR`; keep the private runtime path short enough for Linux `SUN_LEN` until the tracked limitation is fixed. + ## Local sources of truth Read these when behavior or schema details are uncertain: diff --git a/tests/nono/capabilities/__init__.py b/tests/nono/capabilities/__init__.py new file mode 100644 index 0000000..7ebbe51 --- /dev/null +++ b/tests/nono/capabilities/__init__.py @@ -0,0 +1 @@ +"""Capability-specific Nono policy tests.""" diff --git a/tests/nono/capabilities/core/README.md b/tests/nono/capabilities/core/README.md new file mode 100644 index 0000000..bab60fc --- /dev/null +++ b/tests/nono/capabilities/core/README.md @@ -0,0 +1,20 @@ +# Slice 1 core policy proofs + +The hermetic contract tests inspect reviewed core/provider fragments, trusted composition, generated settings with no shell/background tool exposure, package-lock evidence, and the repository-managed LM Studio registration: + +```bash +python -m unittest tests.nono.capabilities.core.test_policy +node --test --experimental-strip-types extensions/lm-studio/index.test.ts +``` + +The mandatory Linux host gate requires Nono 0.74.x. It creates only isolated HOME/XDG/session roots and temporary profile paths. It proves Nono composition and inherited command-denial/caller-edge behavior, personal/work Pi startup with the managed LM Studio extension loaded from isolated settings, real managed-extension model requests to the fixture endpoint, raw credential filtering, cross-session state denial, read-only versus read/write worktrees, and real selected-versus-adjacent port enforcement using loopback listeners on ports 1234 and 1235. Nono 0.74's grant is destination-port-only, as recorded in ADR 0003: + +```bash +PYTHONPATH=. python tests/nono/capabilities/core/host_gate.py +``` + +The gate fails instead of skipping when Nono, Pi, or either fixture port is unavailable. It hashes active Pi settings, the unmanaged LM Studio source, and active Nono profile links before execution and fails if verification changes any of them. While quarantine is deferred, it also requires the active legacy source to match its reviewed lock digest; Slice 3 must replace that precondition with active-path absence and quarantined-file identity during the atomic cutover. + +## Activation remains blocked + +Slice 1 does not mutate active Pi settings, credentials, packages, or Nono profile links. The reviewed LM Studio source remains blocked in `config/pi-package-lock.json` until maintenance verifies the recorded legacy hash, quarantines the unmanaged active file outside Pi auto-discovery, and Slice 3 promotes this source into an immutable installed snapshot. Provider credential routes are schema-validated only; live credential evidence remains a separate explicitly approved operation. diff --git a/tests/nono/capabilities/core/__init__.py b/tests/nono/capabilities/core/__init__.py new file mode 100644 index 0000000..82a1d87 --- /dev/null +++ b/tests/nono/capabilities/core/__init__.py @@ -0,0 +1 @@ +"""Restrictive core and provider composition tests.""" diff --git a/tests/nono/capabilities/core/host_gate.py b/tests/nono/capabilities/core/host_gate.py new file mode 100644 index 0000000..5a662e2 --- /dev/null +++ b/tests/nono/capabilities/core/host_gate.py @@ -0,0 +1,724 @@ +#!/usr/bin/env python3 +"""Mandatory Linux/Nono 0.74 behavioral gate for Slice 1 core policies.""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import socket +import stat +import subprocess +import sys +import tempfile +import threading +from contextlib import ExitStack, closing +from pathlib import Path +from typing import Any + +from tests.nono.capabilities.core.policy import compose_profile, isolated_settings, load_profile +from tests.nono.harness.fixtures import isolated_environment + + +ROOT = Path(__file__).resolve().parents[4] +PROFILES = ROOT / "profiles" / "nono" +LM_STUDIO = ROOT / "extensions" / "lm-studio" / "index.ts" +PI_CLI = Path( + "/home/jb/.bun/install/global/node_modules/" + "@earendil-works/pi-coding-agent/dist/bundle/cli.js" +) +PACKAGE_LOCK = ROOT / "config" / "pi-package-lock.json" +LEGACY_LM_STUDIO = Path("/home/jb/.pi/agent/extensions/lm-studio.ts") +ACTIVE_STATE_PATHS = ( + Path("/home/jb/.pi/agent/settings.json"), + LEGACY_LM_STUDIO, + Path("/home/jb/.config/nono/profiles/pi-local.json"), + Path("/home/jb/.config/nono/profiles/pi-work.json"), + Path("/home/jb/.config/nono/profiles/pi-orca-local.json"), + Path("/home/jb/.config/nono/profiles/pi-harness-tools.json"), +) +RAW_CANARIES = { + "ANTHROPIC_API_KEY": "slice1-anthropic-canary", + "GH_TOKEN": "slice1-gh-canary", + "GITHUB_TOKEN": "slice1-github-canary", + "MORPH_API_KEY": "slice1-morph-canary", + "OPENAI_API_KEY": "slice1-openai-canary", + "SSH_AUTH_SOCK": "/tmp/slice1-forbidden-agent.sock", +} + + +class HostGateError(RuntimeError): + """The mandatory Slice 1 host proof failed.""" + + +def _active_state_snapshot() -> dict[str, tuple[str, str, int]]: + snapshot: dict[str, tuple[str, str, int]] = {} + for path in ACTIVE_STATE_PATHS: + if path.is_symlink(): + target = str(path.readlink()) + payload = path.resolve(strict=True).read_bytes() + elif path.is_file(): + target = "regular" + payload = path.read_bytes() + else: + target = "absent" + payload = b"" + snapshot[str(path)] = ( + target, + hashlib.sha256(payload).hexdigest(), + path.lstat().st_mode if path.exists() or path.is_symlink() else 0, + ) + return snapshot + + +def _read_regular_file(path: Path, *, limit: int = 1024 * 1024) -> bytes: + descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW) + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode) or before.st_size > limit: + raise HostGateError(f"expected one bounded regular file: {path}") + chunks: list[bytes] = [] + total = 0 + while True: + chunk = os.read(descriptor, min(65536, limit + 1 - total)) + if not chunk: + break + chunks.append(chunk) + total += len(chunk) + if total > limit: + raise HostGateError(f"file exceeds verification limit: {path}") + after = os.fstat(descriptor) + if (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns) != ( + after.st_dev, + after.st_ino, + after.st_size, + after.st_mtime_ns, + ): + raise HostGateError(f"file changed during verification: {path}") + return b"".join(chunks) + finally: + os.close(descriptor) + + +def _validate_lm_studio_sources() -> bytes: + lock = json.loads(PACKAGE_LOCK.read_text(encoding="utf-8")) + entries = [ + entry for entry in lock.get("extensions", []) if entry.get("name") == "lm-studio" + ] + if len(entries) != 1: + raise HostGateError("package lock must contain exactly one LM Studio entry") + reviewed = entries[0].get("reviewed", {}) + managed_source = _read_regular_file(LM_STUDIO) + managed_digest = hashlib.sha256(managed_source).hexdigest() + if reviewed.get("sha256") != managed_digest: + raise HostGateError("managed LM Studio source does not match package-lock evidence") + legacy_source = _read_regular_file(LEGACY_LM_STUDIO) + legacy_digest = hashlib.sha256(legacy_source).hexdigest() + if reviewed.get("legacySourceSha256") != legacy_digest: + raise HostGateError("legacy LM Studio source drifted before approved quarantine") + return managed_source + + +def _validate_static_profiles(nono: str, environment: dict[str, str]) -> None: + profiles = sorted(PROFILES.glob("pi-core-*.json")) + sorted( + PROFILES.glob("provider-*.json") + ) + for profile in profiles: + result = subprocess.run( + [nono, "profile", "validate", "--strict", str(profile), "--json"], + env=environment, + capture_output=True, + text=True, + check=False, + timeout=10, + ) + if result.returncode != 0: + raise HostGateError(f"strict validation failed for {profile}: {result.stdout}") + + +def _prove_provider_fragment_is_not_launchable( + nono: str, environment: dict[str, str] +) -> None: + result = subprocess.run( + [ + nono, + "run", + "--profile", + str(PROFILES / "provider-lm-studio.json"), + "--", + "/usr/bin/env", + ], + env=environment, + capture_output=True, + text=True, + input="", + check=False, + timeout=20, + ) + if result.returncode == 0 or "directory is not readable" not in ( + result.stdout + result.stderr + ): + raise HostGateError("provider fragment was directly launchable") + + +def _prove_nono_composition(nono: str, session: Any, base_environment: dict[str, str]) -> None: + profiles = session.home / ".config" / "nono" / "profiles" + profiles.mkdir(parents=True, mode=0o700, exist_ok=True) + os.chmod(profiles, 0o700) + (profiles / "slice-one-parent-a.json").write_text( + json.dumps( + { + "extends": "default", + "meta": {"name": "slice-one-parent-a"}, + "groups": {"exclude": ["system_write_linux", "user_tools"]}, + "network": {"block": True}, + "environment": { + "allow_vars": ["PATH"], + "deny_vars": ["SLICE_ONE_SECRET_A"], + }, + "open_urls": {"allow_origins": [], "allow_localhost": False}, + "allow_launch_services": False, + } + ), + encoding="utf-8", + ) + (profiles / "slice-one-parent-b.json").write_text( + json.dumps( + { + "extends": "default", + "meta": {"name": "slice-one-parent-b"}, + "network": {"block": False, "connect_port": [1234]}, + "environment": { + "allow_vars": ["HOME"], + "deny_vars": ["SLICE_ONE_SECRET_B"], + }, + } + ), + encoding="utf-8", + ) + (profiles / "slice-one-child.json").write_text( + json.dumps( + { + "extends": ["slice-one-parent-a", "slice-one-parent-b"], + "meta": {"name": "slice-one-child"}, + } + ), + encoding="utf-8", + ) + for profile_name, parent_name, decision in ( + ("slice-one-command-parent", "default", "deny"), + ("slice-one-command-child", "slice-one-command-parent", "allow"), + ): + (profiles / f"{profile_name}.json").write_text( + json.dumps( + { + "extends": parent_name, + "meta": {"name": profile_name}, + "groups": {"exclude": ["system_write_linux"]}, + "command_policies": { + "commands": { + "echo": { + "executable": "/usr/bin/echo", + "from": { + "session": { + "sandbox": { + "fs_read_file": ["/usr/bin/echo"] + }, + "invocation_policy": {"default": decision}, + } + }, + } + } + }, + } + ), + encoding="utf-8", + ) + environment = dict(base_environment) + environment.update( + { + "SLICE_ONE_SECRET_A": "canary-a", + "SLICE_ONE_SECRET_B": "canary-b", + "SLICE_ONE_UNLISTED": "canary-c", + } + ) + shown = subprocess.run( + [nono, "profile", "show", "slice-one-child", "--json"], + env=environment, + capture_output=True, + text=True, + check=False, + timeout=10, + ) + if shown.returncode != 0: + raise HostGateError(shown.stderr + shown.stdout) + resolved = json.loads(shown.stdout) + if resolved.get("network", {}).get("block") is not True: + raise HostGateError("Nono multiple inheritance widened a blocked network") + observed = subprocess.run( + [nono, "run", "--profile", "slice-one-child", "--", "/usr/bin/env"], + env=environment, + capture_output=True, + text=True, + input="", + check=False, + timeout=20, + ) + if observed.returncode != 0: + raise HostGateError(observed.stderr + observed.stdout) + child_environment = { + line.partition("=")[0] + for line in observed.stdout.splitlines() + if "=" in line + } + if not {"HOME", "PATH"}.issubset(child_environment): + raise HostGateError("Nono did not merge parent environment allowlists") + if child_environment.intersection( + {"SLICE_ONE_SECRET_A", "SLICE_ONE_SECRET_B", "SLICE_ONE_UNLISTED"} + ): + raise HostGateError("Nono environment composition exposed denied variables") + command_policy = subprocess.run( + [ + nono, + "why", + "--profile", + "slice-one-command-child", + "--command", + "echo", + "--json", + "--", + "hello", + ], + env=environment, + capture_output=True, + text=True, + check=False, + timeout=10, + ) + if command_policy.returncode != 0: + raise HostGateError(command_policy.stderr + command_policy.stdout) + try: + command_decision = json.loads(command_policy.stdout) + except json.JSONDecodeError as error: + raise HostGateError( + f"Nono command policy query returned invalid JSON: " + f"{command_policy.stdout!r} {command_policy.stderr!r}" + ) from error + if command_decision.get("status") != "denied": + raise HostGateError("Nono child command policy widened an inherited denial") + if command_decision.get("policy_source") != ( + "command_policies.commands.echo.from.session.invocation_policy" + ): + raise HostGateError("Nono command policy used an unexpected caller edge") + + +def _listener(port: int, stack: ExitStack, http_requests: dict[int, int]) -> None: + server = stack.enter_context(closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM))) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + server.bind(("127.0.0.1", port)) + except OSError as error: + raise HostGateError(f"loopback fixture port {port} is unavailable: {error}") from error + server.listen() + http_requests[port] = 0 + + def serve() -> None: + while True: + try: + connection, _address = server.accept() + except OSError: + return + with connection: + request = connection.recv(4096) + if request.startswith(b"GET /v1/models "): + http_requests[port] += 1 + body = b'{"data":[{"id":"slice-one-model","name":"Slice One Model"}]}' + connection.sendall( + b"HTTP/1.1 200 OK\r\n" + + f"Content-Length: {len(body)}\r\n".encode("ascii") + + b"Content-Type: application/json\r\nConnection: close\r\n\r\n" + + body + ) + else: + connection.sendall(b"slice-1-ok") + + threading.Thread(target=serve, daemon=True).start() + + +def _prepare_state(session: Any, context: str, lm_studio_extension: Path) -> Path: + agent = session.state / "pi-sbx" / context / "agent" + sessions = session.state / "pi-sbx" / context / "sessions" + cache = session.cache / "pi-sbx" / context + for path in (agent, sessions, cache): + path.mkdir(parents=True, mode=0o700) + os.chmod(path, 0o700) + settings = isolated_settings("lm-studio", lm_studio_extension=lm_studio_extension) + (agent / "settings.json").write_text( + json.dumps(settings, sort_keys=True) + "\n", + encoding="utf-8", + ) + os.chmod(agent / "settings.json", 0o600) + instructions = session.home / ".pi" / "agent" / "AGENTS.md" + instructions.parent.mkdir(parents=True, mode=0o700) + instructions.write_text("fixture instructions\n", encoding="utf-8") + return agent + + +def _write_probe( + repository: Path, + other_tmp: Path, + other_state: Path, + other_repository: Path, + installed_extension: Path, + unselected_config: Path, +) -> Path: + probe = repository / "slice_one_probe.py" + probe.write_text( + """\ +import json, os, socket +from pathlib import Path + +ports = {} +for port in (1234, 1235): + try: + with socket.create_connection((\"127.0.0.1\", port), timeout=1) as connection: + connection.sendall(b\"SLICE_ONE_PROBE\") + ports[str(port)] = connection.recv(32).decode(\"utf-8\") + except Exception as error: + ports[str(port)] = type(error).__name__ + +def can_read(path): + try: + Path(path).read_bytes() + return True + except OSError: + return False + +def can_write(path): + try: + Path(path).write_text(\"changed\", encoding=\"utf-8\") + return True + except OSError: + return False + +print(json.dumps({ + \"ports\": ports, + \"rawCredentials\": sorted(name for name in %r if name in os.environ), + \"otherTmpReadable\": can_read(%r), + \"otherStateReadable\": can_read(%r), + \"otherRepositoryReadable\": can_read(%r), + \"installedExtensionWritable\": can_write(%r), + \"unselectedConfigReadable\": can_read(%r), + \"workdirWritable\": can_write(Path.cwd() / \"probe-write.txt\"), +}, sort_keys=True)) +""" + % ( + tuple(sorted(RAW_CANARIES)), + str(other_tmp / "private.txt"), + str(other_state), + str(other_repository), + str(installed_extension), + str(unselected_config), + ), + encoding="utf-8", + ) + return probe + + +def _write_refresh_runner(repository: Path, installed_extension: Path) -> Path: + runner = repository / "lm_studio_refresh.mjs" + runner.write_text( + f"""\ +import registerLmStudio from {json.dumps(installed_extension.as_uri())}; +let refreshModels; +registerLmStudio({{ + registerProvider(name, config) {{ + if (name !== "lm-studio" || typeof config.refreshModels !== "function") {{ + throw new Error("managed LM Studio provider registration is invalid"); + }} + refreshModels = config.refreshModels; + }}, +}}); +const models = await refreshModels({{ + allowNetwork: true, + signal: AbortSignal.timeout(3000), +}}); +console.log(JSON.stringify({{ modelIds: models.map((model) => model.id) }})); +""", + encoding="utf-8", + ) + return runner + + +def _run( + nono: str, + profile: Path, + repository: Path, + environment: dict[str, str], + command: list[str], +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + nono, + "run", + "--profile", + str(profile), + "--allow-cwd", + "--workdir", + str(repository), + "--", + *command, + ], + cwd=repository, + env=environment, + capture_output=True, + text=True, + input="", + check=False, + timeout=30, + ) + + +def _last_json(stdout: str) -> dict[str, Any]: + for line in reversed(stdout.splitlines()): + try: + value = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(value, dict): + return value + raise HostGateError(f"command produced no JSON object: {stdout[-1000:]}") + + +def run_host_gate() -> dict[str, Any]: + if sys.platform != "linux": + raise HostGateError("Slice 1 host gate requires Linux") + nono = shutil.which("nono", path="/usr/bin:/bin") + if nono is None: + raise HostGateError("Nono is unavailable") + version = subprocess.run( + [nono, "--version"], + capture_output=True, + text=True, + check=False, + timeout=10, + env={"PATH": "/usr/bin:/bin", "LANG": "C", "LC_ALL": "C"}, + ) + if version.returncode != 0 or not version.stdout.strip().startswith("nono 0.74."): + raise HostGateError(f"Nono 0.74.x is required; observed {version.stdout.strip()!r}") + if not PI_CLI.is_file(): + raise HostGateError(f"Pi CLI is unavailable: {PI_CLI}") + managed_lm_studio_source = _validate_lm_studio_sources() + + outcomes: dict[str, Any] = {} + active_state_before = _active_state_snapshot() + listener_requests: dict[int, int] = {} + with ExitStack() as listeners: + _listener(1234, listeners, listener_requests) + _listener(1235, listeners, listener_requests) + # Nono's command-policy supervisor uses a Unix socket under TMPDIR; + # keep the isolated fixture path below Linux SUN_LEN. + with isolated_environment(prefix="s1-") as fixture: + installed_extension = fixture.root / "installed" / "lm-studio" / "index.ts" + installed_extension.parent.mkdir(parents=True, mode=0o700) + installed_extension.write_bytes(managed_lm_studio_source) + os.chmod(installed_extension, 0o400) + unselected_config = fixture.root / "unselected" / "auth.json" + unselected_config.parent.mkdir(parents=True, mode=0o700) + unselected_config.write_text("fixture credential config\n", encoding="utf-8") + os.chmod(unselected_config, 0o600) + sessions = {name: fixture.session(name) for name in ("personal", "work", "other")} + for session in sessions.values(): + (session.tmp / "private.txt").write_text("private\n", encoding="utf-8") + (session.repo / "private.txt").write_text("private\n", encoding="utf-8") + personal_environment = sessions["personal"].environment(fixture.env) + _validate_static_profiles(nono, personal_environment) + _prove_provider_fragment_is_not_launchable(nono, personal_environment) + _prove_nono_composition( + nono, + sessions["personal"], + personal_environment, + ) + other_state = sessions["other"].state / "pi-sbx" / "other" + other_state.mkdir(parents=True, mode=0o700) + (other_state / "private.txt").write_text("private\n", encoding="utf-8") + for context in ("personal", "work"): + session = sessions[context] + other = sessions["other"] + launch_temporary_directory = Path( + tempfile.mkdtemp(prefix=f"s1{context[0]}-", dir="/tmp") + ) + os.chmod(launch_temporary_directory, 0o700) + listeners.callback( + shutil.rmtree, + launch_temporary_directory, + ignore_errors=True, + ) + launch_environment = session.environment(fixture.env) + launch_environment["TMPDIR"] = str(launch_temporary_directory) + _prepare_state(session, context, installed_extension) + probe = _write_probe( + session.repo, + other.tmp, + other_state / "private.txt", + other.repo / "private.txt", + installed_extension, + unselected_config, + ) + refresh_runner = _write_refresh_runner(session.repo, installed_extension) + profile = compose_profile( + load_profile(PROFILES / f"pi-core-{context}.json"), + load_profile(PROFILES / "provider-lm-studio.json"), + workdir_access="read" if context == "personal" else "readwrite", + temporary_directory=launch_temporary_directory, + lm_studio_extension=installed_extension, + ) + profile_path = fixture.root / f"generated-{context}.json" + profile_path.write_text( + json.dumps(profile, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + os.chmod(profile_path, 0o600) + validation = subprocess.run( + [nono, "profile", "validate", "--strict", str(profile_path)], + env=fixture.env, + capture_output=True, + text=True, + check=False, + timeout=10, + ) + if validation.returncode != 0: + raise HostGateError(validation.stderr + validation.stdout) + + probe_environment = dict(launch_environment) + probe_environment.update(RAW_CANARIES) + probe_result = _run( + nono, + profile_path, + session.repo, + probe_environment, + ["/usr/bin/python3", str(probe)], + ) + if probe_result.returncode != 0: + raise HostGateError(probe_result.stderr + probe_result.stdout) + observed = _last_json(probe_result.stdout) + expected_write = context == "work" + if observed.get("ports", {}).get("1234") != "slice-1-ok": + raise HostGateError(f"{context}: selected LM Studio port was denied") + if observed.get("ports", {}).get("1235") == "slice-1-ok": + raise HostGateError(f"{context}: adjacent loopback port was allowed") + if observed.get("rawCredentials"): + raise HostGateError(f"{context}: raw credential names reached the sandbox") + if ( + observed.get("otherTmpReadable") + or observed.get("otherStateReadable") + or observed.get("otherRepositoryReadable") + ): + raise HostGateError(f"{context}: cross-context data was readable") + if observed.get("installedExtensionWritable"): + raise HostGateError(f"{context}: installed extension was writable") + if observed.get("unselectedConfigReadable"): + raise HostGateError(f"{context}: unselected configuration was readable") + if observed.get("workdirWritable") is not expected_write: + raise HostGateError(f"{context}: workdir access did not match the declaration") + + rpc = _run( + nono, + profile_path, + session.repo, + launch_environment, + [ + "/usr/bin/node", + str(PI_CLI), + "--mode", + "rpc", + "--no-session", + "--no-skills", + ], + ) + # EOF exits RPC after the process has initialized. A successful + # startup may produce no response because no command was sent. + if rpc.returncode != 0: + raise HostGateError(f"{context}: Pi startup failed: {rpc.stderr}") + + requests_before_refresh = listener_requests[1234] + model_refresh = _run( + nono, + profile_path, + session.repo, + launch_environment, + [ + "/usr/bin/node", + "--experimental-strip-types", + str(refresh_runner), + ], + ) + if model_refresh.returncode != 0: + raise HostGateError( + f"{context}: LM Studio model refresh failed: " + f"{model_refresh.stderr}{model_refresh.stdout}" + ) + refreshed_models = _last_json(model_refresh.stdout) + if refreshed_models.get("modelIds") != ["slice-one-model"]: + raise HostGateError(f"{context}: LM Studio model response was not mapped") + if listener_requests[1234] != requests_before_refresh + 1: + raise HostGateError( + f"{context}: LM Studio refresh did not request the exact model endpoint" + ) + outcomes[context] = observed + + if _active_state_snapshot() != active_state_before: + raise HostGateError("Slice 1 verification mutated active Pi or Nono state") + tested_profiles = sorted(PROFILES.glob("pi-core-*.json")) + sorted( + PROFILES.glob("provider-*.json") + ) + return { + "suite": "slice-1-host", + "status": "passed", + "nonoVersion": version.stdout.strip().split()[-1], + "contexts": sorted(outcomes), + "loopbackChecks": 4, + "isolationChecks": 12, + "piStartupChecks": 2, + "providerRequestChecks": 2, + "directLaunchDenialChecks": 1, + "staticProfileChecks": len(tested_profiles), + "compositionChecks": 3, + "commandPolicyChecks": 2, + "profileDigests": { + profile.name: hashlib.sha256(profile.read_bytes()).hexdigest() + for profile in tested_profiles + }, + "lmStudioDigest": hashlib.sha256(managed_lm_studio_source).hexdigest(), + } + + +def main() -> int: + active_before: dict[str, tuple[str, str, int]] | None = None + try: + active_before = _active_state_snapshot() + result = run_host_gate() + if _active_state_snapshot() != active_before: + raise HostGateError("Slice 1 verification mutated active Pi or Nono state") + print(json.dumps(result, sort_keys=True)) + except (HostGateError, OSError, subprocess.SubprocessError, ValueError) as error: + if active_before is not None: + try: + if _active_state_snapshot() != active_before: + error = HostGateError( + "Slice 1 verification mutated active Pi or Nono state after " + f"failing: {error}" + ) + except OSError as snapshot_error: + error = HostGateError( + f"Could not verify active state after failure: {snapshot_error}; " + f"original failure: {error}" + ) + print(json.dumps({"suite": "slice-1-host", "status": "failed", "error": str(error)})) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/nono/capabilities/core/policy.py b/tests/nono/capabilities/core/policy.py new file mode 100644 index 0000000..26cbf27 --- /dev/null +++ b/tests/nono/capabilities/core/policy.py @@ -0,0 +1,251 @@ +"""Trusted Slice 1 policy composition contract used by capability tests.""" + +from __future__ import annotations + +import copy +import hashlib +import json +import os +import re +import stat +from pathlib import Path +from typing import Any, Literal + + +PROVIDER_PROFILE_NAMES = { + "anthropic": "provider-anthropic", + "lm-studio": "provider-lm-studio", + "morph": "provider-morph", + "openai": "provider-openai", +} +PROVIDER_CREDENTIAL_DEFINITIONS = { + "anthropic": { + "upstream": "https://api.anthropic.com", + "credential_key": "ANTHROPIC_API_KEY", + "inject_header": "x-api-key", + "credential_format": "{}", + "env_var": "ANTHROPIC_API_KEY", + }, + "morph": { + "upstream": "https://api.morphllm.com", + "credential_key": "MORPH_API_KEY", + "inject_header": "Authorization", + "credential_format": "Bearer {}", + "env_var": "MORPH_API_KEY", + }, + "openai": { + "upstream": "https://api.openai.com/v1", + "credential_key": "OPENAI_API_KEY", + "inject_header": "Authorization", + "credential_format": "Bearer {}", + "env_var": "OPENAI_API_KEY", + }, +} +RAW_CREDENTIAL_VARIABLES = frozenset( + { + "ANTHROPIC_API_KEY", + "GH_TOKEN", + "GITHUB_TOKEN", + "MORPH_API_KEY", + "OPENAI_API_KEY", + "SSH_AUTH_SOCK", + } +) +_LM_REGISTRATION = re.compile(r"registerProvider\(\s*[\"']lm-studio[\"']") +_ROOT = Path(__file__).resolve().parents[4] +_PROFILES = _ROOT / "profiles" / "nono" +_PACKAGE_LOCK = _ROOT / "config" / "pi-package-lock.json" + + +class CompositionError(ValueError): + """A reviewed core/provider pair cannot produce one narrow launch policy.""" + + +def load_profile(path: Path) -> dict[str, Any]: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise CompositionError(f"profile is not an object: {path}") + return data + + +def compose_profile( + core: dict[str, Any], + provider: dict[str, Any], + *, + workdir_access: Literal["read", "readwrite"], + temporary_directory: Path, + lm_studio_extension: Path | None = None, +) -> dict[str, Any]: + """Materialize one reviewed core and one provider into a launch profile. + + This intentionally does not model Nono inheritance. Nono preserves a blocked + network across inherited parents, so the trusted generator must replace the + core's fail-closed network block with exactly one reviewed provider route. + """ + + profile = copy.deepcopy(core) + core_name = _profile_name(core) + provider_name = _profile_name(provider) + if core_name not in {"pi-core-personal", "pi-core-work"}: + raise CompositionError(f"unapproved core profile: {core_name}") + if core != load_profile(_PROFILES / f"{core_name}.json"): + raise CompositionError(f"core profile differs from reviewed source: {core_name}") + expected_provider = PROVIDER_PROFILE_NAMES.get( + provider_name.removeprefix("provider-") + ) + if expected_provider != provider_name: + raise CompositionError(f"unapproved provider profile: {provider_name}") + if provider != load_profile(_PROFILES / f"{provider_name}.json"): + raise CompositionError( + f"provider profile differs from reviewed source: {provider_name}" + ) + _validate_provider_network(provider_name, provider.get("network")) + + profile["extends"] = "default" + profile["meta"] = { + "name": f"generated-{core_name.removeprefix('pi-core-')}-{provider_name.removeprefix('provider-')}", + "description": "Generated Slice 1 composition fixture", + } + profile["workdir"] = {"access": workdir_access} + profile["network"] = copy.deepcopy(provider["network"]) + + environment = profile.get("environment") + if not isinstance(environment, dict): + raise CompositionError("core profile has no environment policy") + denied = set(environment.get("deny_vars", [])) + if denied != RAW_CREDENTIAL_VARIABLES: + raise CompositionError("core raw-credential deny list is incomplete") + + filesystem = profile.get("filesystem") + if not isinstance(filesystem, dict): + raise CompositionError("core profile has no filesystem policy") + if not temporary_directory.is_absolute(): + raise CompositionError("launch temporary directory must be absolute") + try: + metadata = temporary_directory.lstat() + resolved_temporary_directory = temporary_directory.resolve(strict=True) + except OSError as error: + raise CompositionError("launch temporary directory must already exist") from error + if ( + temporary_directory.is_symlink() + or not stat.S_ISDIR(metadata.st_mode) + or resolved_temporary_directory != temporary_directory + or metadata.st_uid != os.getuid() + or stat.S_IMODE(metadata.st_mode) & 0o077 + or resolved_temporary_directory in {Path("/"), Path("/tmp"), Path.home()} + ): + raise CompositionError( + "launch temporary directory must be one private owned directory" + ) + mutable_directories = list(filesystem.get("allow", [])) + if "$TMPDIR" in mutable_directories or "/tmp" in mutable_directories: + raise CompositionError("core profile contains a broad temporary grant") + mutable_directories.append(str(resolved_temporary_directory)) + filesystem["allow"] = sorted(set(mutable_directories)) + set_variables = dict(environment.get("set_vars", {})) + set_variables["TMPDIR"] = str(resolved_temporary_directory) + environment["set_vars"] = set_variables + if provider_name == "provider-lm-studio": + if lm_studio_extension is None: + raise CompositionError("LM Studio requires one installed extension path") + _validate_lm_studio_extension(lm_studio_extension) + reads = list(filesystem.get("read", [])) + reads.append(str(lm_studio_extension)) + filesystem["read"] = sorted(set(reads)) + elif lm_studio_extension is not None: + raise CompositionError("non-LM providers cannot load the LM Studio extension") + + return profile + + +def isolated_settings( + provider: str, + *, + lm_studio_extension: Path | None = None, +) -> dict[str, Any]: + if provider not in PROVIDER_PROFILE_NAMES: + raise CompositionError(f"unknown provider: {provider}") + extensions: list[str] = [] + if provider == "lm-studio": + if lm_studio_extension is None: + raise CompositionError("LM Studio settings require one extension path") + _validate_lm_studio_extension(lm_studio_extension) + extensions.append(str(lm_studio_extension)) + elif lm_studio_extension is not None: + raise CompositionError("LM Studio extension is forbidden for another provider") + return { + "defaultProjectTrust": "never", + "defaultTools": ["read", "edit", "write"], + "enableInstallTelemetry": False, + "extensions": extensions, + "packages": [], + "quietStartup": True, + } + + +def assert_single_lm_studio_registration(sources: list[Path]) -> None: + registrations: list[tuple[Path, int]] = [] + for source in sources: + contents = source.read_text(encoding="utf-8") + if "http://localhost:1234" in contents: + raise CompositionError(f"legacy localhost LM Studio URL remains: {source}") + registrations.extend( + (source, match.start()) for match in _LM_REGISTRATION.finditer(contents) + ) + if len(registrations) != 1: + raise CompositionError( + "expected exactly one LM Studio provider registration; found " + + str(len(registrations)) + ) + + +def _validate_lm_studio_extension(path: Path) -> None: + if not path.is_absolute() or path.is_symlink() or not path.is_file(): + raise CompositionError("LM Studio extension must be one absolute regular file") + lock = json.loads(_PACKAGE_LOCK.read_text(encoding="utf-8")) + entries = [ + entry for entry in lock.get("extensions", []) if entry.get("name") == "lm-studio" + ] + if len(entries) != 1: + raise CompositionError("package lock must contain one LM Studio entry") + expected_digest = entries[0].get("reviewed", {}).get("sha256") + observed_digest = hashlib.sha256(path.read_bytes()).hexdigest() + if expected_digest != observed_digest: + raise CompositionError("LM Studio extension differs from reviewed lock source") + + +def _profile_name(profile: dict[str, Any]) -> str: + meta = profile.get("meta") + if not isinstance(meta, dict) or not isinstance(meta.get("name"), str): + raise CompositionError("profile has no name") + return meta["name"] + + +def _validate_provider_network(name: str, network: Any) -> None: + if not isinstance(network, dict) or network.get("block") is not False: + raise CompositionError(f"provider route is not enabled: {name}") + credentials = network.get("credentials", []) + connect_ports = network.get("connect_port", []) + if name == "provider-lm-studio": + if ( + set(network) != {"block", "connect_port"} + or credentials + or connect_ports != [1234] + ): + raise CompositionError("LM Studio must grant only connect port 1234") + return + expected_credential = name.removeprefix("provider-") + if set(network) != { + "block", + "allow_domain", + "credentials", + "custom_credentials", + }: + raise CompositionError(f"provider network contains an extra route: {name}") + if network["allow_domain"] or credentials != [expected_credential] or connect_ports: + raise CompositionError(f"provider must activate only its own route: {name}") + definitions = network.get("custom_credentials") + if definitions != { + expected_credential: PROVIDER_CREDENTIAL_DEFINITIONS[expected_credential] + }: + raise CompositionError(f"provider credential definition mismatch: {name}") diff --git a/tests/nono/capabilities/core/test_policy.py b/tests/nono/capabilities/core/test_policy.py new file mode 100644 index 0000000..0eb9407 --- /dev/null +++ b/tests/nono/capabilities/core/test_policy.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import copy +import hashlib +import json +import tempfile +import unittest +from pathlib import Path + +from .policy import ( + CompositionError, + PROVIDER_CREDENTIAL_DEFINITIONS, + PROVIDER_PROFILE_NAMES, + RAW_CREDENTIAL_VARIABLES, + assert_single_lm_studio_registration, + compose_profile, + isolated_settings, + load_profile, +) + + +ROOT = Path(__file__).resolve().parents[4] +PROFILES = ROOT / "profiles" / "nono" +LM_STUDIO = ROOT / "extensions" / "lm-studio" / "index.ts" +PI_INSTALL = "/home/jb/.bun/install/global/node_modules/@earendil-works/pi-coding-agent" +JITI_INSTALL = "/home/jb/.bun/install/global/node_modules/jiti" +GLOBAL_INSTRUCTIONS = "/home/jb/.pi/agent/AGENTS.md" + + +class PolicyContractTests(unittest.TestCase): + def test_cores_are_fail_closed_and_separate_state(self) -> None: + for context in ("personal", "work"): + with self.subTest(context=context): + profile = load_profile(PROFILES / f"pi-core-{context}.json") + self.assertEqual(profile["extends"], "default") + self.assertEqual(profile["workdir"], {"access": "none"}) + self.assertEqual(profile["network"], {"block": True}) + self.assertEqual( + set(profile["environment"]["deny_vars"]), + RAW_CREDENTIAL_VARIABLES, + ) + self.assertNotIn("SSH_AUTH_SOCK", profile["environment"]["allow_vars"]) + self.assertIn("system_write_linux", profile["groups"]["exclude"]) + self.assertIn("user_tools", profile["groups"]["exclude"]) + grants = json.dumps(profile["filesystem"], sort_keys=True) + self.assertIn(f"pi-sbx/{context}", grants) + other = "work" if context == "personal" else "personal" + self.assertNotIn(f"pi-sbx/{other}", grants) + self.assertNotIn("$WORKDIR", grants) + self.assertNotIn('"/tmp"', grants) + self.assertNotIn("$TMPDIR", grants) + self.assertEqual( + set(profile["filesystem"]["allow"]), + { + f"$XDG_CACHE_HOME/pi-sbx/{context}", + f"$XDG_STATE_HOME/pi-sbx/{context}/agent", + f"$XDG_STATE_HOME/pi-sbx/{context}/sessions", + }, + ) + self.assertEqual( + profile["filesystem"]["read"], + [PI_INSTALL, JITI_INSTALL], + ) + self.assertEqual( + set(profile["filesystem"]["read_file"]), + {GLOBAL_INSTRUCTIONS, "/usr/bin/node"}, + ) + self.assertEqual( + set(profile["filesystem"]["allow_file"]), + {"/dev/null", "/dev/stderr", "/dev/stdout", "/dev/tty"}, + ) + for capability in ("write", "write_file", "unix_socket"): + self.assertEqual(profile["filesystem"].get(capability, []), []) + + def test_provider_profiles_select_one_route(self) -> None: + for provider, profile_name in PROVIDER_PROFILE_NAMES.items(): + with self.subTest(provider=provider): + profile = load_profile(PROFILES / f"{profile_name}.json") + network = profile["network"] + self.assertFalse(network["block"]) + if provider == "lm-studio": + self.assertEqual(set(network), {"block", "connect_port"}) + self.assertEqual(network["connect_port"], [1234]) + self.assertNotIn("credentials", network) + else: + self.assertEqual( + set(network), + {"block", "allow_domain", "credentials", "custom_credentials"}, + ) + self.assertEqual(network["allow_domain"], []) + self.assertEqual(network["credentials"], [provider]) + self.assertEqual( + network["custom_credentials"], + {provider: PROVIDER_CREDENTIAL_DEFINITIONS[provider]}, + ) + self.assertNotIn("connect_port", network) + self.assertEqual(profile["workdir"], {"access": "none"}) + self.assertIn("system_read_linux_core", profile["groups"]["exclude"]) + self.assertIn("system_read_macos", profile["groups"]["exclude"]) + self.assertTrue(profile["environment"]["allow_vars"]) + self.assertEqual( + set(profile["environment"]["deny_vars"]), + RAW_CREDENTIAL_VARIABLES, + ) + self.assertEqual(profile["open_urls"]["allow_origins"], []) + self.assertFalse(profile["allow_launch_services"]) + + def test_trusted_composition_replaces_block_with_one_provider(self) -> None: + core = load_profile(PROFILES / "pi-core-personal.json") + provider = load_profile(PROFILES / "provider-lm-studio.json") + with tempfile.TemporaryDirectory() as directory: + temporary_directory = Path(directory) + generated = compose_profile( + core, + provider, + workdir_access="read", + temporary_directory=temporary_directory, + lm_studio_extension=LM_STUDIO, + ) + self.assertEqual(generated["workdir"], {"access": "read"}) + self.assertIn(str(temporary_directory), generated["filesystem"]["allow"]) + self.assertEqual( + generated["environment"]["set_vars"]["TMPDIR"], + str(temporary_directory), + ) + self.assertEqual(generated["network"]["connect_port"], [1234]) + self.assertFalse(generated["network"]["block"]) + self.assertNotIn("command_policies", generated) + self.assertIn(str(LM_STUDIO), generated["filesystem"]["read"]) + with self.assertRaisesRegex(CompositionError, "cannot load"): + compose_profile( + core, + load_profile(PROFILES / "provider-openai.json"), + workdir_access="read", + temporary_directory=temporary_directory, + lm_studio_extension=LM_STUDIO, + ) + with self.assertRaisesRegex(CompositionError, "private owned"): + compose_profile( + core, + provider, + workdir_access="read", + temporary_directory=Path("/tmp"), + lm_studio_extension=LM_STUDIO, + ) + mutated_core = copy.deepcopy(core) + mutated_core["filesystem"]["allow"].append("/tmp") + with tempfile.TemporaryDirectory() as directory: + with self.assertRaisesRegex(CompositionError, "differs from reviewed"): + compose_profile( + mutated_core, + provider, + workdir_access="read", + temporary_directory=Path(directory), + lm_studio_extension=LM_STUDIO, + ) + + def test_generated_settings_have_no_privileged_resources(self) -> None: + normal = isolated_settings("openai") + self.assertEqual(normal["packages"], []) + self.assertEqual(normal["extensions"], []) + self.assertEqual(normal["defaultTools"], ["read", "edit", "write"]) + self.assertNotIn("bash", normal["defaultTools"]) + self.assertEqual(normal["defaultProjectTrust"], "never") + local = isolated_settings("lm-studio", lm_studio_extension=LM_STUDIO) + self.assertEqual(local["extensions"], [str(LM_STUDIO)]) + with tempfile.TemporaryDirectory() as directory: + unreviewed = Path(directory) / "lm-studio.ts" + unreviewed.write_text("unreviewed\n", encoding="utf-8") + with self.assertRaisesRegex(CompositionError, "reviewed lock source"): + isolated_settings("lm-studio", lm_studio_extension=unreviewed) + serialized = json.dumps(local).lower() + for forbidden in ("pi-workflow", "update", "background", "notify"): + self.assertNotIn(forbidden, serialized) + + def test_lm_studio_registration_is_unique_and_uses_exact_ip(self) -> None: + lock = json.loads((ROOT / "config" / "pi-package-lock.json").read_text(encoding="utf-8")) + entry = next(item for item in lock["extensions"] if item["name"] == "lm-studio") + self.assertEqual(entry["source"], "extensions/lm-studio/index.ts") + self.assertEqual(entry["status"], "blocked") + self.assertEqual( + entry["reviewed"]["sha256"], + hashlib.sha256(LM_STUDIO.read_bytes()).hexdigest(), + ) + assert_single_lm_studio_registration([LM_STUDIO]) + contents = LM_STUDIO.read_text(encoding="utf-8") + self.assertIn('"http://127.0.0.1:1234/v1"', contents) + self.assertNotIn("http://localhost:1234", contents) + with tempfile.TemporaryDirectory() as directory: + duplicate = Path(directory) / "duplicate.ts" + duplicate.write_text( + 'pi.registerProvider("lm-studio", {});\n', + encoding="utf-8", + ) + with self.assertRaisesRegex(CompositionError, "exactly one"): + assert_single_lm_studio_registration([LM_STUDIO, duplicate]) + duplicate.write_text( + 'pi.registerProvider("lm-studio", {});\n' + 'pi.registerProvider("lm-studio", {});\n', + encoding="utf-8", + ) + with self.assertRaisesRegex(CompositionError, "found 2"): + assert_single_lm_studio_registration([duplicate]) + duplicate.write_text( + 'const baseUrl = "http://localhost:1234/v1";\n' + 'pi.registerProvider("lm-studio", {});\n', + encoding="utf-8", + ) + with self.assertRaisesRegex(CompositionError, "legacy localhost"): + assert_single_lm_studio_registration([duplicate]) + + +if __name__ == "__main__": + unittest.main()