diff --git a/.github/workflows/openshell-scaffold.yml b/.github/workflows/openshell-scaffold.yml new file mode 100644 index 00000000..4c6199c8 --- /dev/null +++ b/.github/workflows/openshell-scaffold.yml @@ -0,0 +1,41 @@ +name: OpenShell offline scaffold + +on: + pull_request: + paths: + - 'agent-os/openshell/**' + - '.github/workflows/openshell-scaffold.yml' + push: + branches: [main] + paths: + - 'agent-os/openshell/**' + - '.github/workflows/openshell-scaffold.yml' + +permissions: + contents: read + +jobs: + offline-contract: + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + node: ['20', '22'] + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@v7.0.0 + with: + node-version: ${{ matrix.node }} + - name: Validate offline contracts; do not install OpenShell + run: | + node --check agent-os/openshell/scaffold.mjs + node --check agent-os/openshell/preview.mjs + node --check agent-os/openshell/host-adapter.mjs + node --test agent-os/openshell/scaffold.test.mjs + node --test agent-os/openshell/host-adapter.test.mjs + node agent-os/openshell/preview.mjs agent-os/openshell/fixture.json + node agent-os/openshell/preview.mjs --readiness diff --git a/agent-os/README.md b/agent-os/README.md index 90fd5c72..f5fbb4b0 100644 --- a/agent-os/README.md +++ b/agent-os/README.md @@ -143,3 +143,14 @@ Use this to track recurring spend, success rate, receipt linkage, and budget pre - Agent OS quickstart: https://agoragentic.com/guides/agent-os-quickstart/ - API reference: https://agoragentic.com/docs.html - Discovery check: https://agoragentic.com/api/discovery/check + +## OpenShell preparation scaffold + +The [OpenShell scaffold](openshell/README.md) compiles offline sandbox-policy proposals for Cartographer, Emissary, and Steward against a pinned upstream source contract. It includes lifecycle observation reconciliation, hard-off live invocation, synthetic failure tests, and a [Codex integration handoff](openshell/CODEX_HANDOFF.md), but does not install OpenShell, connect a gateway, provision a sandbox, run an agent, or grant execution/payment authority. No live compatibility or production protection is claimed. + +```sh +node --test agent-os/openshell/scaffold.test.mjs +node --test agent-os/openshell/host-adapter.test.mjs +node agent-os/openshell/preview.mjs agent-os/openshell/fixture.json +node agent-os/openshell/preview.mjs --readiness +``` diff --git a/agent-os/openshell/CODEX_HANDOFF.md b/agent-os/openshell/CODEX_HANDOFF.md new file mode 100644 index 00000000..d71760e4 --- /dev/null +++ b/agent-os/openshell/CODEX_HANDOFF.md @@ -0,0 +1,59 @@ +# OpenShell augmentation and integration handoff + +## Goal and present state + +Integrate OpenShell below Agoragentic's existing control plane, preserving the agent-operated Interchange and all owner, financial, identity, trust, and evidence boundaries. The compiler, offline CLI, observation shaper, offline lifecycle reconciler, synthetic failure tests, and source pin are implemented. A live provider adapter is **not** implemented or qualified. No production migration is authorized by this document. + +## Work sequence + +### 1. Source and build qualification + +Re-read repository instructions and the pinned upstream contracts. Resolve the v0.1.2 release to the recorded commit; verify the SDK package and gateway build rather than trusting a version string. Install only in an owner-approved disposable lab. Verify the generated SDK types accept `createSpec` and verify server-side normalization has not weakened its policy. Upgrade the pin only in a reviewed change that updates the lock and conformance fixtures together. + +Build or select a digest-pinned minimal worker image with the `sandbox` user/group, explicitly required paths and binaries, no credentials, no host-runtime socket, and no production configuration. The example digest is fictional. Validate hard-required Landlock on the actual host. Failure must stop setup, not silently downgrade to best effort. + +Acceptance: recorded package/image/gateway integrity, generated type/schema checks, policy acceptance, and a reproducible host/image definition. These are not yet containment proof. + +### 2. Trusted host adapter + +The integrations repository contains offline lifecycle reconciliation in `host-adapter.mjs`; it is not a live provider, SDK shim, or authority boundary. It validates bounded journal events and preserves unresolved identity, execution, and cleanup states without provider, process, or network effects. A future implementation in the existing platform provider abstraction must first bind to the real server-owned mandate, consent, identity, budget, and provisioning approval boundary; no caller-supplied object may stand in for that authority. `OpenShellClient.connect()` is lazy: a successful construction is not a health check. Require a real `health()` result and verified gateway identity/auth configuration. + +Bind authorization outside the sandbox to the exact owner, tenant/workspace, run, immutable sandbox ID, compiled plan/policy digest, image digest, command, quota, and expiry. Recheck the financial freeze and relevant mandate/consent controls. A caller-supplied `approved: true`, environment flag, self-hash, fake SDK client, or policy advisor proposal is not authorization. Do not accept agent-supplied method names, deployment contracts, policy overrides, `rawSpec`, provider lists, or arbitrary environment variables. + +Use the pinned SDK's `sandbox.create`, `waitReady`, `getConfig`, `exec`/`execStream`, `delete`, and identity-aware `waitDeleted` interfaces only after rechecking their actual types. Confirm effective static and network policy before any worker executes. Keep request data and authority/control-plane clients in separate trust domains. + +Acceptance for this source tranche: local tests exercise complete and interrupted journals, ambiguous create and late identity, cancellation/revocation/timeouts, missing terminal codes, wrong sandbox identity, config mismatch, pending/unknown deletion, invalid ordering, replay, and bounded strict input. Reconciliation never synthesizes success, authorizes retries, or converts caller observations into verified evidence. Live invocation remains hard-off. This does not establish a real host gate, gateway health, or runtime containment. + +### 3. Bounded lab canary and failure cleanup + +Obtain separate owner approval for lab resources; no payment/custody enablement follows. Start with the fixed `/bin/true` canary, then a reviewed read-only worker. Enforce CPU, memory, storage, wall-clock, and output limits in the actual host/runtime, not just metadata. + +Test an approved read succeeds and unapproved hosts/methods/paths, private IPs, cloud metadata, redirects/rebinding, unauthorized files, credential reads, provider attachment, policy widening, and cross-workspace access fail. Use synthetic secrets only. Capture policy identity, sandbox identity, image/build identity, terminal status, and independently observed denials without raw sensitive logs. A network error alone is not proof that a policy denial occurred. + +Test create failure, timeout, cancellation, stream closure without terminal exit, gateway failure, policy drift, owner revocation, interrupted cleanup, and same-name sandbox replacement. Ambiguous create/exec responses are not safe to retry automatically. Track sandbox IDs durably so an orphan can be reconciled. `accepted` deletion stays pending; `unspecified`/unknown stays unknown. Abort of a client RPC is not proof the worker stopped. Verify termination/cleanup separately. + +Acceptance: authenticated, identity-bound live evidence for success, denials, and cleanup. Keep production and live-traffic qualification false until independently reviewed. + +### 4. Role integration, in order + +Cartographer first: supply only sanitized public-source inputs from the existing owner-approved source configuration. Preserve discovery provenance and quotas; no new trust, listing, peer-key, payment, or wallet mutation. Candidates discovered by an agent are not automatically approved egress destinations. + +Emissary second: isolate drafting/inspection. Send contact proposals to the existing host consent/mandate path; do not move sender credentials, key-pinning authority, or transport approval into the worker. Retain first-contact limits and independent owner gates. + +Steward third: consume minimal evidence references and propose bounded maintenance. Do not let it approve its own policy changes, reconfigure the gateway, grant credentials, or merge/deploy itself. Put approval and policy application in separate host components. + +Harness Core/ECF: connect through existing proposal/evidence contracts. Mark imported artifacts as local/unverified until a trusted evidence verifier promotes them. Never treat local plan hashes as portable authority. Risk Fork can evaluate candidate changes, but it is not a substitute for OpenShell containment or the owner's mandate. + +### 5. OpenShell-specific augmentation, after baseline qualification + +Evaluate supervisor middleware for pre-action checks and gateway interceptors for control-plane admission. First inspect the pinned upstream contracts and failure behavior. Keep policy admission fail-closed; do not assume extension existence supplies authenticated Agoragentic authority. Policy advisor/prover output can explain proposed access changes but must remain a recommendation until the independent host approval path authorizes the exact delta. Formal policy reasoning is not business-consent or settlement verification. + +Credential-backed inference/provider routes require a separate approved threat model and endpoint-scoped credential binding. Do not export signer/custody material. Any paid inference or external resource cost requires its own authorized budget. No generic provider attachment or arbitrary network widening is allowed. + +Acceptance: isolated integration tests for unavailable/bypassed middleware, expired/replayed approvals, widened policy, and non-mutating denied requests. No production claims based only on source inspection. + +## Completion report required from the implementation agent + +Report changed files, source/SDK/image/gateway pins, actual test commands and outcomes, evidence provenance, enabled versus default-off paths, outstanding gates, rollback/cleanup status, and the final reviewed commit. Separate local tests, mocked contract tests, live canaries, and production verification. Do not claim subagent review unless real subagent execution was available. + +Rollback before activation is simply leaving the provider unselected. After an approved lab activation, stop new dispatch, revoke the lab grant, terminate only the recorded sandbox IDs, verify cleanup, and preserve redacted evidence. Never delete another workspace's or a same-name replacement's resource. diff --git a/agent-os/openshell/README.md b/agent-os/openshell/README.md new file mode 100644 index 00000000..116b9f38 --- /dev/null +++ b/agent-os/openshell/README.md @@ -0,0 +1,97 @@ +# OpenShell integration scaffold + +**Source-only, offline, default-off. No OpenShell installation, gateway connection, sandbox provisioning, agent execution, package publication, or production activation is performed.** This is a preparation surface under the existing Agent OS examples, not a new supported integration or canonical tool ID. + +Agoragentic owns owner mandates, action approvals, identity, budgets, receipts, and reconciliation. OpenShell is a candidate execution-isolation layer underneath those controls. A sandbox policy must not grant payment, federation, publication, or trust authority. + +## Run the implemented scaffold + +Node.js 20+; no package installation or credentials required. From the repository root: + +```sh +node --test agent-os/openshell/scaffold.test.mjs +node --test agent-os/openshell/host-adapter.test.mjs +node agent-os/openshell/preview.mjs agent-os/openshell/fixture.json +node agent-os/openshell/preview.mjs --readiness +``` + +The fixture uses a **fictional image digest and example hostname**. It demonstrates the format, not an image that can be pulled or a live discovery target. The readiness command describes this scaffold only; it does not inspect a host. `activationSupported` remains false. There is no `apply` command or enabling environment variable. + +## What is implemented + +- A deterministic policy/plan compiler with strict input validation, bounded input, immutable output, and SHA-256 plan/policy digests. +- Separate Cartographer, Emissary, and Steward profiles. Cartographer can propose exact-host/exact-path HTTPS GET/HEAD egress. Emissary and Steward remain network-denied. All profiles are proposal-only. +- A TypeScript SDK `createSpec` candidate with create-time filesystem, process, hard-required Landlock, explicit L7 enforcement, no provider attachment, no environment injection, and no exposed service ports. +- An adapter preparation interface whose invocation path always refuses activation. +- Offline lifecycle reconciliation with strict event ordering, plan and immutable sandbox identity binding, late identity capture after ambiguous creation, terminal exit-code requirements, and unresolved cleanup tracking. Cancellation, deadlines, and revocation do not imply worker termination. No event initiates an operation or authorizes a retry. +- Hard-off live invocation and readiness surfaces that identify the missing real Agent OS authority boundary. Journal output remains caller-reported and does not prove gateway health, policy enforcement, or runtime containment. +- Offline observation shaping that requires a terminal integer exit code, binds an expected sandbox ID, distinguishes pending deletion, and never upgrades caller reports to verified receipts. +- A pinned upstream source record and a staged integration/qualification handoff. + +```js +import { OpenShellScaffoldAdapter } from './scaffold.mjs'; +const adapter = new OpenShellScaffoldAdapter(); +const plan = adapter.prepare(request); // pure local compilation +// await adapter.invoke(...); // always refuses; no live driver is bound +``` + +The lifecycle reconciler accepts a bounded JSON journal, with contiguous `seq` numbers starting at 1. It has no authorization callback or provider client injection point. The existing Agent OS control plane must supply and authenticate any future authority boundary. No worker starts and no success is synthesized from an empty journal. + +```js +import { reconcileOpenShellLifecycle } from './host-adapter.mjs'; +const result = reconcileOpenShellLifecycle(plan, JSON.stringify({ + schema: 'agoragentic.openshell.lifecycle-journal.v1', + planDigest: plan.planDigest, + events: [ + { seq: 1, type: 'create_requested' }, + { seq: 2, type: 'create_unknown' }, + ], +})); +// result.reconciliationRequired === true; result.repeatCreateAllowed === false +``` + +Events cover `create_requested`, `create_unknown`, `create_rejected`, `created`, `ready`, `config_observed`, `exec_started`, `exec_unknown`, `exec_terminal`, `cancelled`, `timed_out`, `revoked`, `delete_requested`, and `deletion_observed`. Identity-bearing events require the original `sandboxId`. Config observations carry `image` and the local `policyDigest`; matching those fields is only a reported local projection match, never verification of the effective protobuf policy. Terminal observations carry `exitCode`; deletion observations carry the existing observation shaper's `outcome`. Unknown fields and invalid sequences fail closed. See the tests for complete and interrupted journal examples. + +The journal digest binds local content, not its author or provenance. Even a completed reported deletion leaves `cleanupVerified` and `terminationVerified` false. `reconciliationRequired: false` means this journal reports no outstanding resource, not that independent cleanup has been proved. Durable journal storage, authenticated host observations, effective-policy verification, real timers, and RPC handling remain the future host's responsibilities. + +## Role boundaries + +| Role | Current proposal | Later host integration, not implemented here | +|---|---|---| +| Cartographer | Explicit public read candidates; no default egress | Observe approved sources, retain provenance, emit discovery candidates without promoting trust | +| Emissary | Offline drafting, no egress or credentials | Submit bounded contact proposals to the existing consent/mandate-controlled host path | +| Steward | Offline evidence review, no egress or mutation | Recommend maintenance and policy changes; owner gates remain outside the sandbox | + +This preserves agent-operated growth and maintenance without letting the agents approve their own authority expansion. These profiles do not contain or start the actual role agents. The only candidate create-time command is `/bin/true`; replacing it with a role worker is a separate reviewed change. + +## Upstream contract and important details + +Reviewed target: **OpenShell v0.1.2**, commit `6648bd0c290efbc41ba131ee9831ee45cd431f94`. See [upstream.lock.json](upstream.lock.json) for file/blob provenance and review scope. No upstream source is vendored. Runtime compatibility remains untested. + +Primary sources at that immutable revision: + +- [TypeScript SDK interface and distribution](https://github.com/NVIDIA/OpenShell/blob/6648bd0c290efbc41ba131ee9831ee45cd431f94/sdk/typescript/README.md) +- [SDK create/exec/deletion types](https://github.com/NVIDIA/OpenShell/blob/6648bd0c290efbc41ba131ee9831ee45cd431f94/sdk/typescript/src/client.ts) +- [Policy protobuf contract](https://github.com/NVIDIA/OpenShell/blob/6648bd0c290efbc41ba131ee9831ee45cd431f94/proto/sandbox.proto) + +`createSpec` uses **camelCase SDK message-init fields**, not CLI YAML. The policy protobuf specifies audit as the unspecified inspected-network enforcement mode; this compiler sets `enforcement: 1` explicitly. `tls: 0` is automatic TLS handling, not TLS bypass. The proposal has no `allowedIps`, uninspected-credential bypass, provider attachment, middleware registration, or `rawSpec` escape hatch. + +OpenShell filesystem/process/Landlock policy must be supplied at creation; sandbox-scoped policy updates cannot introduce static fields later. The proposed image must have the non-root `sandbox` account, `/bin/true`, `/usr/bin/curl`, required runtime paths, `/sandbox/input`, and `/sandbox/output`. None of those image properties are verified by a digest-shaped string. + +The SDK is documented as distributed through GitHub Packages at this release. Do not assume an identically named public npm package is the reviewed build. A later approved setup must authenticate outside the sandbox, pin SDK and gateway versions together, and record package/image integrity. Do not run an unpinned remote installation script as part of this scaffold. + +## Evidence and safety limits + +A local `planDigest` or `policyDigest` is **not** an OpenShell protobuf policy hash, signature, owner approval, attestation, settlement receipt, or proof of work. The digests bind the scaffold's canonical local JSON. Plan validation detects changes against this compiler; it does not authenticate who proposed the plan. + +Observation summaries accept only the small documented projection (`kind`, `sandboxId`, and `exitCode` or `outcome`). Do not feed raw SDK objects, stdout, stderr, prompts, environment variables, credentials, or customer data into them. Output remains `caller_reported_unverified` even when an exit code is zero. A deletion result of `accepted` is pending, not cleanup success. Missing or replaced sandbox identity must not be papered over by inserting an expected ID into a response that did not contain it. + +Hostname screening is syntactic only. It does not resolve DNS or establish SSRF protection, consent, endpoint semantics, redirect safety, or trust. GET/HEAD does not guarantee a remote application is side-effect-free. The host must validate destinations at connection time, control redirects, and test metadata/private-network denial. No real request is made here. + +The runtime deadline is a **host requirement, not an implemented timer**. All runtime-verification flags stay false. CPU/memory/storage quotas, lifecycle cancellation, tenant isolation, gateway identity/authentication, effective policy confirmation, and independent containment evidence remain qualification work. No Docker socket, host credentials, gateway admin token, signer, wallet key, or production database belongs in the worker image or mounts. + +## Integration ownership and next work + +See [CODEX_HANDOFF.md](CODEX_HANDOFF.md). Keep the reusable compiler here; host authority wiring belongs in the platform, while Harness Core should consume only a supported local evidence/proposal interface. Do not create another Harness Core implementation in this repository's migration-pointer directory. Risk Fork/ECF checks are additional gates, not reasons to skip sandbox or owner controls. + +No changes to the canonical integration index, integration counts, paid execution, custody state, trust vocabulary, public readiness claims, or existing runtime selection are made by this scaffold. diff --git a/agent-os/openshell/fixture.json b/agent-os/openshell/fixture.json new file mode 100644 index 00000000..cbbed856 --- /dev/null +++ b/agent-os/openshell/fixture.json @@ -0,0 +1,11 @@ +{ + "schema": "agoragentic.openshell.request.v1", + "role": "cartographer", + "runId": "offline-demo", + "workspace": "agoragentic-lab", + "image": "registry.example.com/agoragentic/worker@sha256:1111111111111111111111111111111111111111111111111111111111111111", + "readEndpoints": [ + { "host": "docs.example.com", "paths": ["/robots.txt", "/.well-known/agent-card.json"] } + ], + "maxRuntimeSeconds": 60 +} diff --git a/agent-os/openshell/host-adapter.mjs b/agent-os/openshell/host-adapter.mjs new file mode 100644 index 00000000..848a58da --- /dev/null +++ b/agent-os/openshell/host-adapter.mjs @@ -0,0 +1,172 @@ +import { createHash } from 'node:crypto'; +import { validateOpenShellPlan, summarizeOpenShellObservation } from './scaffold.mjs'; + +function fail(code) { + const error = new Error(code); + error.code = code; + throw error; +} + +function fields(value, required) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || Object.keys(value).length !== required.length + || required.some((key) => !Object.hasOwn(value, key))) fail('invalid_journal_fields'); +} + +function canonical(value) { + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`; + } + return JSON.stringify(value); +} + +function freeze(value) { + if (value && typeof value === 'object') { + Object.values(value).forEach(freeze); + Object.freeze(value); + } + return value; +} + +const EVENT_FIELDS = Object.freeze({ + create_requested: [], create_unknown: [], create_rejected: [], + created: ['sandboxId'], ready: ['sandboxId'], + config_observed: ['sandboxId', 'image', 'policyDigest'], + exec_started: ['sandboxId'], exec_unknown: ['sandboxId'], + exec_terminal: ['sandboxId', 'exitCode'], + cancelled: [], timed_out: [], revoked: [], + delete_requested: ['sandboxId'], deletion_observed: ['sandboxId', 'outcome'], +}); + +/** + * Replay a bounded, untrusted observation journal. No gateway, authority, + * callback or worker is involved. Sequence validation helps a future host + * reconcile failures; it does not authenticate the observations or the plan. + * JSON text is intentional: this entry point never evaluates object accessors. + */ +export function reconcileOpenShellLifecycle(planInput, journalText) { + const plan = validateOpenShellPlan(planInput); + if (typeof journalText !== 'string' || Buffer.byteLength(journalText, 'utf8') > 65536) fail('bounded_journal_json_required'); + let journal; + try { journal = JSON.parse(journalText); } catch { fail('invalid_journal_json'); } + fields(journal, ['schema', 'planDigest', 'events']); + if (journal.schema !== 'agoragentic.openshell.lifecycle-journal.v1') fail('unsupported_journal_schema'); + if (journal.planDigest !== plan.planDigest) fail('journal_plan_mismatch'); + if (!Array.isArray(journal.events) || journal.events.length > 128) fail('invalid_journal_events'); + + let creation = 'not_requested'; + let sandboxId = null; + let ready = false; + let config = 'not_observed'; + let execution = 'not_started'; + let terminal = null; + let cleanup = 'not_requested'; + let deletion = null; + const interruptions = []; + const requireState = (condition) => { if (!condition) fail('invalid_lifecycle_sequence'); }; + + for (const [index, event] of journal.events.entries()) { + if (!event || !Object.hasOwn(EVENT_FIELDS, event.type)) fail('unsupported_lifecycle_event'); + fields(event, ['seq', 'type', ...EVENT_FIELDS[event.type]]); + if (event.seq !== index + 1) fail('non_contiguous_journal'); + requireState(cleanup !== 'reported_completed' && creation !== 'reported_rejected'); + if (Object.hasOwn(event, 'sandboxId')) { + if (typeof event.sandboxId !== 'string' || !/^[A-Za-z0-9-]{1,80}$/.test(event.sandboxId)) fail('invalid_sandbox_id'); + if (sandboxId !== null && event.sandboxId !== sandboxId) fail('sandbox_identity_mismatch'); + } + const uninterrupted = interruptions.length === 0 && cleanup === 'not_requested'; + switch (event.type) { + case 'create_requested': + requireState(creation === 'not_requested'); + creation = 'pending'; + break; + case 'create_unknown': + requireState(creation === 'pending'); + creation = 'unknown'; + interruptions.push('create_unknown'); + break; + case 'create_rejected': + requireState(['pending', 'unknown'].includes(creation)); + creation = 'reported_rejected'; + break; + case 'created': + // After ambiguous create or cancellation, a late ID is retained for + // cleanup. A second create is never inferred or authorized. + requireState(['pending', 'unknown'].includes(creation)); + sandboxId = event.sandboxId; + creation = 'reported_created'; + break; + case 'ready': + requireState(sandboxId !== null && !ready && uninterrupted); + ready = true; + break; + case 'config_observed': + requireState(ready && config === 'not_observed' && uninterrupted); + if (typeof event.image !== 'string' || typeof event.policyDigest !== 'string') fail('invalid_config_observation'); + config = event.image === plan.request.image && event.policyDigest === plan.policyDigest + ? 'reported_local_projection_match' : 'reported_mismatch'; + if (config === 'reported_mismatch') interruptions.push('config_mismatch'); + break; + case 'exec_started': + requireState(ready && config === 'reported_local_projection_match' && execution === 'not_started' && uninterrupted); + execution = 'running'; + break; + case 'exec_unknown': + requireState(execution === 'running'); + execution = 'unknown'; + interruptions.push('exec_unknown'); + break; + case 'exec_terminal': + requireState(['running', 'unknown'].includes(execution)); + terminal = summarizeOpenShellObservation(plan, sandboxId, { kind: 'exec', sandboxId: event.sandboxId, exitCode: event.exitCode }); + execution = terminal.reportedStatus; + break; + case 'cancelled': + case 'timed_out': + case 'revoked': + requireState(creation !== 'not_requested' && !interruptions.includes(event.type)); + interruptions.push(event.type); + // RPC cancellation/deadline/revocation alone cannot prove termination. + if (execution === 'running') execution = 'unknown'; + break; + case 'delete_requested': + requireState(sandboxId !== null && cleanup === 'not_requested'); + cleanup = 'requested'; + if (execution === 'running') execution = 'unknown'; + break; + case 'deletion_observed': + requireState(cleanup !== 'not_requested'); + deletion = summarizeOpenShellObservation(plan, sandboxId, { kind: 'delete', sandboxId: event.sandboxId, outcome: event.outcome }); + cleanup = deletion.reportedStatus; + break; + default: fail('unsupported_lifecycle_event'); + } + } + + const cleanupOutstanding = sandboxId !== null && cleanup !== 'reported_completed'; + const identityOutstanding = ['pending', 'unknown'].includes(creation); + return freeze({ + schema: 'agoragentic.openshell.lifecycle-reconciliation.v1', + runId: plan.request.runId, workspace: plan.request.workspace, planDigest: plan.planDigest, + journalDigest: `sha256:${createHash('sha256').update(canonical(journal)).digest('hex')}`, + eventCount: journal.events.length, sandboxId, creation, ready, config, execution, + terminal, cleanup, deletion, interruptions, + reconciliationRequired: identityOutstanding || cleanupOutstanding, + terminationVerified: false, cleanupVerified: false, + evidenceClass: 'caller_reported_unverified', + executionAuthority: false, repeatCreateAllowed: false, repeatExecAllowed: false, + providerCalls: false, runtimeContainmentVerified: false, liveTrafficProtected: false, + }); +} + +export function openShellHostReadiness() { + return freeze({ + status: 'offline_lifecycle_reconciliation', activationSupported: false, + providerCalls: false, processStarted: false, networkUsed: false, credentialsUsed: false, + blockers: ['real_agent_os_authority_boundary_not_bound', 'qualified_gateway_and_image_missing', + 'live_containment_not_tested', 'owner_activation_not_approved'], + }); +} + +export async function invokeOpenShell() { fail('openshell_live_adapter_not_implemented'); } diff --git a/agent-os/openshell/host-adapter.test.mjs b/agent-os/openshell/host-adapter.test.mjs new file mode 100644 index 00000000..d5e560ab --- /dev/null +++ b/agent-os/openshell/host-adapter.test.mjs @@ -0,0 +1,137 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { buildOpenShellPlan } from './scaffold.mjs'; +import { reconcileOpenShellLifecycle, invokeOpenShell, openShellHostReadiness } from './host-adapter.mjs'; + +const fixture = JSON.parse(readFileSync(new URL('./fixture.json', import.meta.url), 'utf8')); +const plan = buildOpenShellPlan(fixture); +const id = 'original-id'; +const e = (type, extra = {}) => ({ type, ...extra }); +const created = [e('create_requested'), e('created', { sandboxId: id })]; +const configured = [...created, e('ready', { sandboxId: id }), e('config_observed', { + sandboxId: id, image: plan.request.image, policyDigest: plan.policyDigest, +})]; +const running = [...configured, e('exec_started', { sandboxId: id })]; +const terminal = [...running, e('exec_terminal', { sandboxId: id, exitCode: 0 })]; +const journal = (events) => ({ schema: 'agoragentic.openshell.lifecycle-journal.v1', planDigest: plan.planDigest, + events: events.map((event, index) => ({ seq: index + 1, ...event })) }); +const replay = (events) => reconcileOpenShellLifecycle(plan, JSON.stringify(journal(events))); + +test('complete observed lifecycle binds identity and plan but never creates verified evidence', () => { + const events = [...terminal, e('delete_requested', { sandboxId: id }), + e('deletion_observed', { sandboxId: id, outcome: 'accepted' }), + e('deletion_observed', { sandboxId: id, outcome: 'completed' })]; + const result = replay(events); + assert.equal(result.cleanup, 'reported_completed'); + assert.equal(result.execution, 'reported_success'); + assert.equal(result.reconciliationRequired, false); + for (const field of ['terminationVerified', 'cleanupVerified', 'executionAuthority', 'providerCalls', 'runtimeContainmentVerified']) { + assert.equal(result[field], false); + } + assert.equal(result.evidenceClass, 'caller_reported_unverified'); + assert.equal(result.sandboxId, id); + assert.equal(result.planDigest, plan.planDigest); + assert.deepEqual(result, replay(events)); + assert.ok(Object.isFrozen(result.interruptions)); + assert.ok(Object.isFrozen(result.terminal)); +}); + +test('empty journal invents no successful execution or cleanup', () => { + const result = replay([]); + assert.equal(result.sandboxId, null); + assert.equal(result.execution, 'not_started'); + assert.equal(result.terminal, null); + assert.equal(result.cleanup, 'not_requested'); +}); + +test('ambiguous create prevents retry and late identity remains available for cleanup', () => { + const uncertain = [e('create_requested'), e('create_unknown')]; + assert.equal(replay(uncertain).reconciliationRequired, true); + assert.equal(replay(uncertain).repeatCreateAllowed, false); + assert.throws(() => replay([...uncertain, e('create_requested')]), { code: 'invalid_lifecycle_sequence' }); + assert.throws(() => replay([...uncertain, e('created', { sandboxId: id }), e('ready', { sandboxId: id })]), { code: 'invalid_lifecycle_sequence' }); + const late = replay([...uncertain, e('cancelled'), e('created', { sandboxId: id }), e('delete_requested', { sandboxId: id })]); + assert.equal(late.sandboxId, id); + assert.equal(late.cleanup, 'requested'); + assert.equal(late.reconciliationRequired, true); +}); + +for (const type of ['cancelled', 'timed_out', 'revoked', 'exec_unknown']) { + test(`${type} cannot imply worker termination or authorize another exec`, () => { + const stop = e(type, type === 'exec_unknown' ? { sandboxId: id } : {}); + const result = replay([...running, stop]); + assert.equal(result.execution, 'unknown'); + assert.equal(result.terminationVerified, false); + assert.equal(result.reconciliationRequired, true); + assert.throws(() => replay([...running, stop, e('exec_started', { sandboxId: id })]), { code: 'invalid_lifecycle_sequence' }); + assert.equal(replay([...running, stop, e('exec_terminal', { sandboxId: id, exitCode: 137 })]).execution, 'reported_failure'); + }); +} + +for (const outcome of ['accepted', 'already_absent', 'unspecified', 'unknown']) { + test(`deletion ${outcome} remains unresolved`, () => { + const result = replay([...terminal, e('delete_requested', { sandboxId: id }), e('deletion_observed', { sandboxId: id, outcome })]); + assert.equal(result.reconciliationRequired, true); + assert.equal(result.cleanupVerified, false); + assert.notEqual(result.cleanup, 'reported_completed'); + }); +} + +test('same-name replacement identity cannot be used for cleanup or terminal status', () => { + assert.throws(() => replay([...terminal, e('delete_requested', { sandboxId: 'replacement' })]), { code: 'sandbox_identity_mismatch' }); + assert.throws(() => replay([...running, e('exec_terminal', { sandboxId: 'replacement', exitCode: 0 })]), { code: 'sandbox_identity_mismatch' }); +}); + +test('policy or image mismatch blocks subsequent execution but permits original-ID cleanup', () => { + for (const key of ['image', 'policyDigest']) { + const events = structuredClone(configured); + events.at(-1)[key] = 'different'; + assert.equal(replay(events).config, 'reported_mismatch'); + assert.throws(() => replay([...events, e('exec_started', { sandboxId: id })]), { code: 'invalid_lifecycle_sequence' }); + assert.equal(replay([...events, e('delete_requested', { sandboxId: id })]).cleanup, 'requested'); + } +}); + +test('out-of-order, duplicate and post-cleanup operations are rejected', () => { + const invalid = [ + [e('ready', { sandboxId: id })], + [...created, e('exec_started', { sandboxId: id })], + [...configured, e('exec_terminal', { sandboxId: id, exitCode: 0 })], + [...created, e('created', { sandboxId: id })], + [...terminal, e('exec_terminal', { sandboxId: id, exitCode: 0 })], + [...created, e('deletion_observed', { sandboxId: id, outcome: 'completed' })], + [...created, e('delete_requested', { sandboxId: id }), e('delete_requested', { sandboxId: id })], + [...created, e('delete_requested', { sandboxId: id }), e('deletion_observed', { sandboxId: id, outcome: 'completed' }), e('ready', { sandboxId: id })], + [e('create_requested'), e('create_rejected'), e('create_requested')], + ]; + for (const events of invalid) assert.throws(() => replay(events), { code: 'invalid_lifecycle_sequence' }); +}); + +test('missing, boolean, negative and nonterminal exit codes cannot count as success', () => { + for (const exitCode of [null, true, -1, 256, '0']) { + assert.throws(() => replay([...running, e('exec_terminal', { sandboxId: id, exitCode })]), { code: 'terminal_exit_code_required' }); + } + assert.throws(() => replay([...running, e('exec_terminal', { sandboxId: id })]), { code: 'invalid_journal_fields' }); +}); + +test('journal schema, lineage, count and sequence are strict', () => { + const data = journal(created); + for (const mutation of [ + { ...data, approved: true }, { ...data, schema: 'other' }, { ...data, planDigest: 'wrong' }, + { ...data, events: [{ ...data.events[0], seq: 2 }] }, + { ...data, events: [{ ...data.events[0], method: 'create' }] }, + { ...data, events: Array(129).fill(data.events[0]) }, + ]) assert.throws(() => reconcileOpenShellLifecycle(plan, JSON.stringify(mutation))); + assert.throws(() => reconcileOpenShellLifecycle(plan, 'x'.repeat(65537)), { code: 'bounded_journal_json_required' }); + assert.throws(() => reconcileOpenShellLifecycle(plan, '{'), { code: 'invalid_journal_json' }); + let invoked = false; + assert.throws(() => reconcileOpenShellLifecycle(plan, { get events() { invoked = true; return []; } }), { code: 'bounded_journal_json_required' }); + assert.equal(invoked, false); +}); + +test('live invocation refuses even with caller-provided approval or fake client', async () => { + await assert.rejects(invokeOpenShell({ approved: true, client: { create() { throw Error('called'); } } }), { code: 'openshell_live_adapter_not_implemented' }); + assert.equal(openShellHostReadiness().activationSupported, false); + assert.equal(openShellHostReadiness().providerCalls, false); +}); diff --git a/agent-os/openshell/preview.mjs b/agent-os/openshell/preview.mjs new file mode 100644 index 00000000..8413ac57 --- /dev/null +++ b/agent-os/openshell/preview.mjs @@ -0,0 +1,36 @@ +import { constants, openSync, readSync, fstatSync, closeSync } from 'node:fs'; +import { buildOpenShellPlan, openShellReadiness } from './scaffold.mjs'; + +// Offline only. No installer, SDK import, subprocess, network, or apply command. +try { + const args = process.argv.slice(2); + if (args.length === 1 && args[0] === '--readiness') { + console.log(JSON.stringify(openShellReadiness(), null, 2)); + } else { + if (args.length !== 1 || args[0].startsWith('-')) throw new Error('usage: node preview.mjs REQUEST.json | --readiness'); + const fd = openSync(args[0], constants.O_RDONLY | (constants.O_NONBLOCK ?? 0)); + let text; + try { + if (!fstatSync(fd).isFile()) throw new Error('request_must_be_regular_file'); + const bytes = Buffer.alloc(65537); + let size = 0; + while (size < bytes.length) { + const count = readSync(fd, bytes, size, bytes.length - size, null); + if (!count) break; + size += count; + } + if (size > 65536) throw new Error('request_too_large'); + text = bytes.subarray(0, size).toString('utf8'); + } finally { closeSync(fd); } + let request; + try { request = JSON.parse(text); } catch { throw new Error('invalid_request_json'); } + console.log(JSON.stringify(buildOpenShellPlan(request), null, 2)); + } +} catch (error) { + // Do not echo request contents or filesystem paths from native errors. + const message = error.code && !error.code.startsWith('E') ? error.code + : ['invalid_request_json', 'request_too_large', 'request_must_be_regular_file'].includes(error.message) ? error.message + : 'preview_failed: use one request JSON path or --readiness'; + console.error(message); + process.exitCode = 2; +} diff --git a/agent-os/openshell/scaffold.mjs b/agent-os/openshell/scaffold.mjs new file mode 100644 index 00000000..c2afd0a7 --- /dev/null +++ b/agent-os/openshell/scaffold.mjs @@ -0,0 +1,224 @@ +import { createHash } from 'node:crypto'; + +// Source-contract target, NOT a claim of installed or runtime-tested compatibility. +export const UPSTREAM = Object.freeze({ + release: 'v0.1.2', + commit: '6648bd0c290efbc41ba131ee9831ee45cd431f94', + sdk: '@nvidia/openshell-sdk', +}); +export const ROLES = Object.freeze(['cartographer', 'emissary', 'steward']); +const REQUEST_SCHEMA = 'agoragentic.openshell.request.v1'; +const PLAN_SCHEMA = 'agoragentic.openshell.plan.v1'; +const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']); + +function fail(code) { + const error = new Error(code); + error.code = code; + throw error; +} + +// Do not invoke getters/toJSON, accept class instances, or copy dangerous keys. +function dataOnly(value, depth = 0, seen = new Set(), budget = { nodes: 0 }) { + if (++budget.nodes > 4096 || depth > 16) fail('input_too_complex'); + if (value === null || typeof value === 'boolean') return value; + if (typeof value === 'string') { + if (value.length > 4096) fail('input_string_too_long'); + return value; + } + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (!value || typeof value !== 'object') fail('json_data_required'); + if (seen.has(value)) fail('cyclic_input'); + const array = Array.isArray(value); + if (!array && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail('plain_object_required'); + if (Object.getOwnPropertySymbols(value).length) fail('symbol_keys_denied'); + seen.add(value); + const output = array ? [] : {}; + for (const key of Object.getOwnPropertyNames(value)) { + if (array && key === 'length') continue; + if (FORBIDDEN_KEYS.has(key)) fail('unsafe_key'); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) fail('accessor_or_hidden_field_denied'); + if (array && !/^(0|[1-9][0-9]*)$/.test(key)) fail('array_property_denied'); + output[key] = dataOnly(descriptor.value, depth + 1, seen, budget); + } + if (array && (value.length > 4096 || Object.keys(output).length !== value.length)) fail('sparse_array_denied'); + seen.delete(value); + return output; +} + +function fields(value, allowed, required = []) { + if (!value || Array.isArray(value) || typeof value !== 'object') fail('object_required'); + if (Object.keys(value).some((key) => !allowed.includes(key))) fail('unknown_field'); + if (required.some((key) => !Object.hasOwn(value, key))) fail('missing_field'); +} + +function slug(value, max = 63) { + if (typeof value !== 'string' || value.length > max || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value)) fail('invalid_identifier'); + return value; +} + +function canonical(value) { + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`; + } + return JSON.stringify(value); +} + +function digest(value) { + return `sha256:${createHash('sha256').update(canonical(value)).digest('hex')}`; +} + +function freeze(value) { + if (value && typeof value === 'object') { + Object.values(value).forEach(freeze); + Object.freeze(value); + } + return value; +} + +function endpoint(value) { + fields(value, ['host', 'paths'], ['host', 'paths']); + const { host, paths } = value; + // Syntactic screening only: DNS resolution/rebinding checks belong to the host. + if (typeof host !== 'string' || host.length > 253 || host !== host.toLowerCase() + || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(host) + || /(?:^|\.)(?:localhost|local|internal|lan|home|onion)$/.test(host)) fail('invalid_public_hostname'); + if (!Array.isArray(paths) || paths.length < 1 || paths.length > 16) fail('invalid_paths'); + for (const path of paths) { + if (typeof path !== 'string' || path.length > 256 || !/^\/[A-Za-z0-9._~/-]*$/.test(path) + || path.includes('//') || path.split('/').some((part) => part === '.' || part === '..')) fail('invalid_exact_path'); + } + return { host, paths: [...new Set(paths)].sort() }; +} + +/** Compile an offline proposal. It is not an approval, executor, or sandbox. */ +export function buildOpenShellPlan(input) { + const request = dataOnly(input); + fields(request, ['schema', 'role', 'runId', 'workspace', 'image', 'readEndpoints', 'maxRuntimeSeconds'], + ['schema', 'role', 'runId', 'workspace', 'image']); + if (request.schema !== REQUEST_SCHEMA) fail('unsupported_request_schema'); + if (!ROLES.includes(request.role)) fail('unsupported_role'); + slug(request.runId, 24); + slug(request.workspace); + if (typeof request.image !== 'string' || request.image.length > 300 + || !/^[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)+@sha256:[a-f0-9]{64}$/.test(request.image)) fail('digest_pinned_image_required'); + const seconds = Object.hasOwn(request, 'maxRuntimeSeconds') ? request.maxRuntimeSeconds : 60; + if (!Number.isInteger(seconds) || seconds < 1 || seconds > 300) fail('invalid_runtime_limit'); + const endpoints = Object.hasOwn(request, 'readEndpoints') ? request.readEndpoints : []; + if (!Array.isArray(endpoints) || endpoints.length > 16) fail('invalid_endpoints'); + if (request.role !== 'cartographer' && endpoints.length) fail('role_egress_denied'); + const normalized = endpoints.map(endpoint).sort((a, b) => a.host < b.host ? -1 : a.host > b.host ? 1 : 0); + if (new Set(normalized.map((item) => item.host)).size !== normalized.length) fail('duplicate_host'); + const normalizedRequest = { ...request, readEndpoints: normalized, maxRuntimeSeconds: seconds }; + const networkPolicies = {}; + normalized.forEach((item, index) => { + const name = `read_${index}`; + networkPolicies[name] = { + name, + binaries: [{ path: '/usr/bin/curl' }], + endpoints: [{ + host: item.host, port: 443, protocol: 'rest', + tls: 0, enforcement: 1, + rules: item.paths.flatMap((path) => ['GET', 'HEAD'].map((method) => ({ allow: { method, path } }))), + }], + }; + }); + const policy = { + version: 1, + filesystem: { + includeWorkdir: false, + readOnly: ['/usr', '/bin', '/lib', '/lib64', '/etc/ssl/certs', '/etc/hosts', '/etc/resolv.conf', '/sandbox/input'], + readWrite: ['/sandbox/output', '/tmp'], + }, + landlock: { compatibility: 'hard_requirement' }, + process: { runAsUser: 'sandbox', runAsGroup: 'sandbox' }, + networkPolicies, + }; + const plan = { + schema: PLAN_SCHEMA, + request: normalizedRequest, + upstream: { ...UPSTREAM }, + status: 'offline_proposal', + authority: { execution: false, provisioning: false, payment: false, trustMutation: false, policyApproval: false }, + // camelCase is the TypeScript SDK message-init shape, NOT CLI YAML. + createSpec: { + name: `ag-${request.role}-${request.runId}`, + workspace: request.workspace, + image: request.image, + command: ['/bin/true'], + tty: false, + gpu: false, + environment: {}, + providers: [], + serviceExposures: [], + policy, + }, + hostRequirements: { + maxRuntimeSeconds: seconds, + deadlineEnforced: false, + imageVerified: false, + gatewayVerified: false, + dnsAndRedirectContainmentVerified: false, + runtimePolicyVerified: false, + ownerApprovalRequired: true, + credentialBoundary: 'control_plane_only', + }, + policyDigest: digest(policy), + evidenceClass: 'local_plan_not_runtime_proof', + }; + return freeze({ ...plan, planDigest: digest(plan) }); +} + +/** Reject altered or extended plans, including self-rehashed policy overrides. */ +export function validateOpenShellPlan(input) { + const plan = dataOnly(input); + const rebuilt = buildOpenShellPlan(plan.request); + if (canonical(plan) !== canonical(rebuilt)) fail('plan_mismatch'); + return rebuilt; +} + +/** Offline SDK-result shaping. Caller reports NEVER become verified receipts. */ +export function summarizeOpenShellObservation(planInput, expectedSandboxId, input) { + const plan = validateOpenShellPlan(planInput); + if (typeof expectedSandboxId !== 'string' || !/^[A-Za-z0-9-]{1,80}$/.test(expectedSandboxId)) fail('invalid_sandbox_id'); + const observation = dataOnly(input); + fields(observation, ['kind', 'sandboxId', 'exitCode', 'outcome'], ['kind', 'sandboxId']); + if (observation.sandboxId !== expectedSandboxId) fail('sandbox_identity_mismatch'); + let reportedStatus; + if (observation.kind === 'exec') { + if (Object.hasOwn(observation, 'outcome')) fail('unexpected_outcome'); + if (!Number.isInteger(observation.exitCode) || observation.exitCode < 0 || observation.exitCode > 255) fail('terminal_exit_code_required'); + reportedStatus = observation.exitCode === 0 ? 'reported_success' : 'reported_failure'; + } else if (observation.kind === 'delete') { + if (Object.hasOwn(observation, 'exitCode')) fail('unexpected_exit_code'); + const states = { completed: 'reported_completed', accepted: 'reported_pending', already_absent: 'reported_absent_unverified', unspecified: 'unknown', unknown: 'unknown' }; + if (!Object.hasOwn(states, observation.outcome)) fail('unknown_deletion_outcome'); + reportedStatus = states[observation.outcome]; + } else fail('unsupported_observation'); + return freeze({ + schema: 'agoragentic.openshell.observation.v1', + runId: plan.request.runId, + planDigest: plan.planDigest, + sandboxId: expectedSandboxId, + kind: observation.kind, + reportedStatus, + evidenceClass: 'caller_reported_unverified', + verified: false, + settlementProof: false, + executionAuthority: false, + }); +} + +export function openShellReadiness() { + return freeze({ + status: 'scaffold_only', upstream: { ...UPSTREAM }, + activationSupported: false, liveTrafficProtected: false, + blockers: ['qualified_host_adapter_missing', 'image_and_gateway_not_verified', 'live_containment_not_tested', 'owner_activation_not_approved'], + }); +} + +export class OpenShellScaffoldAdapter { + prepare(request) { return buildOpenShellPlan(request); } + async invoke() { fail('openshell_activation_not_implemented'); } +} diff --git a/agent-os/openshell/scaffold.test.mjs b/agent-os/openshell/scaffold.test.mjs new file mode 100644 index 00000000..96ca1402 --- /dev/null +++ b/agent-os/openshell/scaffold.test.mjs @@ -0,0 +1,218 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { UPSTREAM, ROLES, buildOpenShellPlan, validateOpenShellPlan, summarizeOpenShellObservation, openShellReadiness, OpenShellScaffoldAdapter } from './scaffold.mjs'; + +const fixture = JSON.parse(readFileSync(new URL('./fixture.json', import.meta.url), 'utf8')); +const fresh = () => structuredClone(fixture); +const plan = () => buildOpenShellPlan(fresh()); +const rejects = (input, code) => assert.throws(() => buildOpenShellPlan(input), { code }); + +for (const role of ROLES) { + test(`${role}: offline, authority-free, immutable plan`, () => { + const input = { ...fresh(), role, readEndpoints: [] }; + const result = buildOpenShellPlan(input); + assert.equal(result.status, 'offline_proposal'); + assert.ok(Object.values(result.authority).every((value) => value === false)); + assert.deepEqual(result.createSpec.command, ['/bin/true']); + assert.deepEqual(result.createSpec.providers, []); + assert.deepEqual(result.createSpec.environment, {}); + assert.deepEqual(result.createSpec.serviceExposures, []); + assert.equal(result.hostRequirements.deadlineEnforced, false); + assert.equal(result.hostRequirements.ownerApprovalRequired, true); + assert.throws(() => { result.createSpec.policy.filesystem.readWrite.push('/'); }, TypeError); + assert.deepEqual(validateOpenShellPlan(JSON.parse(JSON.stringify(result))), result); + input.readEndpoints.push({ host: 'changed.example.com', paths: ['/'] }); + assert.equal(result.request.readEndpoints.length, 0); + }); +} + +test('cartographer emits exact GET/HEAD rules with explicit enforcement, no credential/IP escape hatch', () => { + const policy = plan().createSpec.policy; + assert.equal(policy.landlock.compatibility, 'hard_requirement'); + assert.equal(policy.filesystem.includeWorkdir, false); + assert.deepEqual(policy.filesystem.readWrite, ['/sandbox/output', '/tmp']); + assert.deepEqual(policy.process, { runAsUser: 'sandbox', runAsGroup: 'sandbox' }); + const rule = policy.networkPolicies.read_0; + assert.deepEqual(rule.binaries, [{ path: '/usr/bin/curl' }]); + const item = rule.endpoints[0]; + assert.equal(item.enforcement, 1); + assert.equal(item.protocol, 'rest'); + assert.equal(item.tls, 0); + assert.equal(item.port, 443); + assert.ok(item.rules.every(({ allow }) => ['GET', 'HEAD'].includes(allow.method))); + assert.deepEqual(Object.keys(item).sort(), ['host', 'port', 'protocol', 'tls', 'enforcement', 'rules'].sort()); +}); + +test('deterministic normalization and digest binds all policy and host-limit inputs', () => { + const a = fresh(); + a.readEndpoints.push({ host: 'a.example.com', paths: ['/z', '/a', '/a'] }); + const b = fresh(); + b.readEndpoints.unshift({ host: 'a.example.com', paths: ['/a', '/z'] }); + assert.deepEqual(buildOpenShellPlan(a), buildOpenShellPlan(b)); + const c = { ...b, maxRuntimeSeconds: 61 }; + assert.notEqual(buildOpenShellPlan(c).planDigest, buildOpenShellPlan(b).planDigest); + assert.equal(buildOpenShellPlan(c).policyDigest, buildOpenShellPlan(b).policyDigest); +}); + +test('optional omitted fields use bounded defaults, not permissive policy', () => { + const input = fresh(); + delete input.readEndpoints; + delete input.maxRuntimeSeconds; + const result = buildOpenShellPlan(input); + assert.deepEqual(result.createSpec.policy.networkPolicies, {}); + assert.equal(result.hostRequirements.maxRuntimeSeconds, 60); +}); + +for (const host of ['127.0.0.1', '169.254.169.254', '::1', '[::1]', '2130706433', '0177.0.0.1', '0x7f000001', 'localhost', 'foo.localhost', 'metadata.internal', 'foo.local', 'foo.lan', 'foo.onion', '*.example.com', '**.example.com', 'https://example.com', 'user@example.com', 'example.com:443', 'example.com.', 'EXAMPLE.com', 'exämple.com', 'example.com\n', 'a..example.com']) { + test(`reject hostname ${JSON.stringify(host)}`, () => { + rejects({ ...fresh(), readEndpoints: [{ host, paths: ['/'] }] }, 'invalid_public_hostname'); + }); +} +for (const path of ['/*', '/**', '//evil', '/x//y', '/../x', '/a/./b', '/a/../b', '/%2e%2e/x', '/x%2fy', '/x?token=secret', '/x#fragment', '/x\\y', '/x\n', 'relative', 7, null]) { + test(`reject path ${JSON.stringify(path)}`, () => { + rejects({ ...fresh(), readEndpoints: [{ host: 'docs.example.com', paths: [path] }] }, 'invalid_exact_path'); + }); +} +for (const value of [0, -1, 301, 1.5, '60', null, true]) { + test(`reject runtime limit ${JSON.stringify(value)}`, () => rejects({ ...fresh(), maxRuntimeSeconds: value }, 'invalid_runtime_limit')); +} +for (const field of ['enabled', 'approved', 'providers', 'environment', 'rawSpec', 'policy', 'command', 'gateway', 'token', 'filesystem']) { + test(`deny unrecognized authority/override field ${field}`, () => rejects({ ...fresh(), [field]: true }, 'unknown_field')); +} +for (const role of ['emissary', 'steward']) { + test(`${role} cannot inherit cartographer egress`, () => rejects({ ...fresh(), role }, 'role_egress_denied')); +} +for (const image of ['ubuntu:latest', 'ubuntu', 'repo/name@sha256:abc', 'repo/name@sha256:' + 'A'.repeat(64), 'repo/name@sha256:' + '1'.repeat(64) + '\n']) { + test(`reject unpinned/noncanonical image ${JSON.stringify(image)}`, () => rejects({ ...fresh(), image }, 'digest_pinned_image_required')); +} + +test('reject duplicate hosts, excessive endpoints, null endpoints and empty path lists', () => { + rejects({ ...fresh(), readEndpoints: [fixture.readEndpoints[0], fixture.readEndpoints[0]] }, 'duplicate_host'); + rejects({ ...fresh(), readEndpoints: Array(17).fill(fixture.readEndpoints[0]) }, 'invalid_endpoints'); + rejects({ ...fresh(), readEndpoints: null }, 'invalid_endpoints'); + rejects({ ...fresh(), readEndpoints: [{ host: 'example.com', paths: [] }] }, 'invalid_paths'); + rejects({ ...fresh(), readEndpoints: [{ host: 'example.com', paths: ['/'], allowedIps: ['10.0.0.1'] }] }, 'unknown_field'); +}); + +test('reject invalid schema, missing fields, role and identifiers', () => { + rejects({ ...fresh(), schema: 'unknown' }, 'unsupported_request_schema'); + const input = fresh(); delete input.image; + rejects(input, 'missing_field'); + rejects({ ...fresh(), role: 'admin' }, 'unsupported_role'); + for (const runId of ['../x', '', 'a'.repeat(25), 'a\n', '--option']) rejects({ ...fresh(), runId }, 'invalid_identifier'); +}); + +test('reject non-data objects, accessors without invoking them, cyclic and hostile keys', () => { + rejects(new Date(), 'plain_object_required'); + let touched = false; + const input = fresh(); + Object.defineProperty(input, 'token', { enumerable: true, get() { touched = true; return 'secret'; } }); + rejects(input, 'accessor_or_hidden_field_denied'); + assert.equal(touched, false); + const cyclic = fresh(); cyclic.extra = cyclic; + rejects(cyclic, 'cyclic_input'); + rejects(JSON.parse('{"__proto__":{"polluted":true}}'), 'unsafe_key'); + rejects({ ...fresh(), extra: () => true }, 'json_data_required'); + rejects({ ...fresh(), extra: Infinity }, 'json_data_required'); + rejects({ ...fresh(), readEndpoints: Array(2) }, 'sparse_array_denied'); + assert.equal({}.polluted, undefined); +}); + +test('reject edited, extended, and authority-bearing serialized plans', () => { + for (const change of [ + (p) => { p.authority.execution = true; }, + (p) => { p.createSpec.policy.networkPolicies.read_0.endpoints[0].enforcement = 0; }, + (p) => { p.createSpec.rawSpec = { policy: {} }; }, + (p) => { p.extra = true; }, + (p) => { p.planDigest = 'sha256:' + '0'.repeat(64); }, + ]) { + const changed = structuredClone(plan()); change(changed); + assert.throws(() => validateOpenShellPlan(changed), { code: 'plan_mismatch' }); + } +}); + +for (const [exitCode, expected] of [[0, 'reported_success'], [1, 'reported_failure'], [137, 'reported_failure']]) { + test(`execution report ${exitCode} remains unverified`, () => { + const report = summarizeOpenShellObservation(plan(), 'sandbox-123', { kind: 'exec', sandboxId: 'sandbox-123', exitCode }); + assert.equal(report.reportedStatus, expected); + assert.equal(report.verified, false); + assert.equal(report.settlementProof, false); + assert.equal(report.executionAuthority, false); + assert.equal(report.evidenceClass, 'caller_reported_unverified'); + }); +} +for (const exitCode of [undefined, null, '', '0', -1, 256, 0.5, false]) { + test(`missing/invalid terminal exit ${String(exitCode)} is not success`, () => { + const report = { kind: 'exec', sandboxId: 'sandbox-123' }; + if (exitCode !== undefined) report.exitCode = exitCode; + assert.throws(() => summarizeOpenShellObservation(plan(), 'sandbox-123', report), { code: 'terminal_exit_code_required' }); + }); +} +for (const [outcome, status] of [['completed', 'reported_completed'], ['accepted', 'reported_pending'], ['already_absent', 'reported_absent_unverified'], ['unspecified', 'unknown'], ['unknown', 'unknown']]) { + test(`deletion ${outcome} preserves pending/uncertainty`, () => { + const result = summarizeOpenShellObservation(plan(), 'original-id', { kind: 'delete', sandboxId: 'original-id', outcome }); + assert.equal(result.reportedStatus, status); + assert.equal(result.verified, false); + }); +} + +test('observations cannot change identity, carry raw logs, or mix terminal types', () => { + assert.throws(() => summarizeOpenShellObservation(plan(), 'original', { kind: 'exec', sandboxId: 'replacement', exitCode: 0 }), { code: 'sandbox_identity_mismatch' }); + assert.throws(() => summarizeOpenShellObservation(plan(), 'original', { kind: 'exec', sandboxId: 'original', exitCode: 0, stdout: 'secret' }), { code: 'unknown_field' }); + assert.throws(() => summarizeOpenShellObservation(plan(), 'original', { kind: 'delete', sandboxId: 'original', outcome: 'future' }), { code: 'unknown_deletion_outcome' }); + assert.throws(() => summarizeOpenShellObservation(plan(), 'original', { kind: 'delete', sandboxId: 'original', outcome: 'completed', exitCode: 0 }), { code: 'unexpected_exit_code' }); +}); + +test('adapter is hard-off regardless of flags, booleans or supplied client', async () => { + let calls = 0; + const adapter = new OpenShellScaffoldAdapter({ enabled: true, client: { create() { calls++; } } }); + assert.equal(adapter.prepare(fresh()).status, 'offline_proposal'); + await assert.rejects(adapter.invoke('create_sandbox', { enabled: true, approved: true, plan: plan() }), { code: 'openshell_activation_not_implemented' }); + assert.equal(calls, 0); + assert.equal(openShellReadiness().activationSupported, false); + assert.equal(openShellReadiness().liveTrafficProtected, false); +}); + +test('upstream pin matches checked-in lock; runtime proof is not claimed', () => { + const lock = JSON.parse(readFileSync(new URL('./upstream.lock.json', import.meta.url), 'utf8')); + for (const key of ['release', 'commit', 'sdk']) assert.equal(UPSTREAM[key], lock[key]); + assert.equal(lock.runtimeTested, false); +}); + +test('CLI is offline, reads fixture, denies apply, limits input, and does not echo malformed secrets', () => { + const cli = fileURLToPath(new URL('./preview.mjs', import.meta.url)); + const file = fileURLToPath(new URL('./fixture.json', import.meta.url)); + const result = JSON.parse(execFileSync(process.execPath, [cli, file], { encoding: 'utf8' })); + assert.deepEqual(result, plan()); + const ready = JSON.parse(execFileSync(process.execPath, [cli, '--readiness'], { encoding: 'utf8' })); + assert.equal(ready.activationSupported, false); + assert.equal(spawnSync(process.execPath, [cli, '--apply'], { encoding: 'utf8' }).status, 2); + const dir = mkdtempSync(join(tmpdir(), 'openshell-scaffold-')); + try { + const bad = join(dir, 'bad.json'); + writeFileSync(bad, '{"secret":"do-not-echo-this"'); + const failed = spawnSync(process.execPath, [cli, bad], { encoding: 'utf8' }); + assert.equal(failed.status, 2); + assert.equal(failed.stderr.includes('do-not-echo-this'), false); + writeFileSync(bad, 'x'.repeat(65537)); + assert.match(spawnSync(process.execPath, [cli, bad], { encoding: 'utf8' }).stderr, /request_too_large/); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('CLI rejects a FIFO without waiting for a writer', { skip: process.platform === 'win32' }, () => { + const cli = fileURLToPath(new URL('./preview.mjs', import.meta.url)); + const dir = mkdtempSync(join(tmpdir(), 'openshell-fifo-')); + try { + const fifo = join(dir, 'request.fifo'); + execFileSync('mkfifo', [fifo]); + const result = spawnSync(process.execPath, [cli, fifo], { encoding: 'utf8', timeout: 5000 }); + assert.equal(result.error, undefined, 'CLI must reject promptly rather than time out waiting for a writer'); + assert.equal(result.status, 2); + assert.match(result.stderr, /request_must_be_regular_file/); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/agent-os/openshell/upstream.lock.json b/agent-os/openshell/upstream.lock.json new file mode 100644 index 00000000..b0c383cb --- /dev/null +++ b/agent-os/openshell/upstream.lock.json @@ -0,0 +1,16 @@ +{ + "schema": "agoragentic.openshell.upstream-lock.v1", + "reviewedAt": "2026-09-28", + "repository": "https://github.com/NVIDIA/OpenShell", + "release": "v0.1.2", + "commit": "6648bd0c290efbc41ba131ee9831ee45cd431f94", + "sdk": "@nvidia/openshell-sdk", + "sdkDistribution": "GitHub Packages at the reviewed release; no package installation performed", + "license": "Apache-2.0", + "runtimeTested": false, + "sourceFiles": [ + { "path": "sdk/typescript/README.md", "gitBlob": "99c4fac9d4d9dfe0904f6fb2510e22154e5b206f" }, + { "path": "sdk/typescript/src/client.ts", "gitBlob": "93ad8ba6d163f409e522a7e9bde53f13eafa9338", "reviewedLines": "1-280" }, + { "path": "proto/sandbox.proto", "gitBlob": "1d837b7f0e1d4e0e9d82763252f214804027c5b6", "reviewedLines": "1-260,300-430" } + ] +}